#!/usr/bin/env node /** * Generate the binary files behind the demo fixtures into public/demo/. * * node scripts/demo-assets/generate.mjs [outDir] * * Every attachment and every entry in the demo Files app points at one of * these, so previews render real content instead of a text placeholder. The * PDFs and PNGs are rendered from the HTML templates below with the Chromium * that Playwright already ships for the test suite; the spreadsheets use * exceljs (a dependency of the app). Word and PowerPoint files need `docx` and * `pptxgenjs`, which are not dependencies of the app: install them anywhere * and point DEMO_ASSET_DEPS at that folder, e.g. * * mkdir /tmp/deps && (cd /tmp/deps && npm init -y && npm i docx pptxgenjs) * DEMO_ASSET_DEPS=/tmp/deps node scripts/demo-assets/generate.mjs * * Without them the Office files are skipped and the existing ones are kept. * The output is committed; rerun only when the fixtures change. */ import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(here, '..', '..'); const OUT = path.resolve(process.argv[2] || path.join(ROOT, 'public', 'demo')); const require = createRequire(path.join(ROOT, 'package.json')); const { chromium } = require('playwright-core'); const ExcelJS = require('exceljs'); let docx = null; let PptxGenJS = null; if (process.env.DEMO_ASSET_DEPS) { const depRequire = createRequire(path.join(path.resolve(process.env.DEMO_ASSET_DEPS), 'package.json')); try { docx = depRequire('docx'); } catch { /* skipped below */ } try { PptxGenJS = depRequire('pptxgenjs'); } catch { /* skipped below */ } } fs.mkdirSync(OUT, { recursive: true }); const written = []; const write = (name, buf) => { fs.writeFileSync(path.join(OUT, name), buf); written.push(`${name} ${(buf.length / 1024).toFixed(0)} KB`); }; // ── Shared look for the rendered documents ───────────────────────────── const CSS = ` @page { size: A4; margin: 0; } * { box-sizing: border-box; } body { margin: 0; font: 11pt/1.5 "Segoe UI", Inter, -apple-system, Helvetica, Arial, sans-serif; color: #18181b; } .page { width: 210mm; min-height: 297mm; padding: 22mm 20mm; position: relative; } h1 { font-size: 26pt; margin: 0 0 4mm; letter-spacing: -0.01em; } h2 { font-size: 13pt; margin: 10mm 0 3mm; } .muted { color: #71717a; } .brand { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14mm; } .logo { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14pt; } .logo i { width: 20px; height: 20px; border-radius: 6px; display: inline-block; } table { width: 100%; border-collapse: collapse; font-size: 10.5pt; } th { text-align: left; font-weight: 600; color: #52525b; border-bottom: 1.5px solid #18181b; padding: 2.5mm 0; } td { padding: 2.5mm 0; border-bottom: 1px solid #e4e4e7; vertical-align: top; } td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; } .total td { border-bottom: 0; font-weight: 700; font-size: 12pt; padding-top: 4mm; } .box { border: 1px solid #e4e4e7; border-radius: 8px; padding: 6mm; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; } .tag { display: inline-block; font-size: 8.5pt; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #3f3f46; } .foot { position: absolute; left: 20mm; right: 20mm; bottom: 14mm; font-size: 8.5pt; color: #a1a1aa; border-top: 1px solid #e4e4e7; padding-top: 3mm; display: flex; justify-content: space-between; } .big { font-size: 30pt; font-weight: 700; letter-spacing: -0.02em; } .mono { font-family: ui-monospace, Consolas, Menlo, monospace; } `; const page = (body) => `
${body}
`; const logo = (name, color) => ``; // Dates in the documents are phrased relative to "this month" so they do not // contradict the fixtures, which float with the clock. const now = new Date(); const month = now.toLocaleString('en-GB', { month: 'long' }); const year = now.getFullYear(); const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).toLocaleString('en-GB', { month: 'long' }); const fmt = (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); const plus = (days) => { const d = new Date(now); d.setDate(d.getDate() + days); return d; }; const DOCS = { // DesignCo invoice, attached to Sarah Kim's mail. 'invoice-designco-2026-089.pdf': page(`
${logo('DesignCo', '#0f8578')}
DesignCo Studio GmbH
Torstraße 112, 10119 Berlin
hello@designco.example

Invoice 2026-089

Billed to
Meridian Labs GmbH
Attn. Nora Lindqvist
Rosenthaler Str. 40, 10178 Berlin
Details
Issued ${fmt(now)}
Due ${fmt(plus(14))}
Project: Website redesign
DescriptionHoursRateAmount
Landing page prototype, v3 (hero option B, asymmetric grid)24€110.00€2,640.00
Design system tokens and component audit12€110.00€1,320.00
Two review calls and revisions4€110.00€440.00
Subtotal€4,400.00
VAT 19%€836.00
Total due€5,236.00

Payment

Bank transfer to DesignCo Studio GmbH, IBAN DE02 1203 0000 0000 2020 51, reference 2026-089. The ${nextMonth} pre-payment has been backed out as agreed.

DesignCo Studio GmbH · HRB 98765 B · VAT DE 812 345 678Page 1 of 1
`), // Hotel voucher for the Lisbon trip. 'hotel-voucher-lisbon.pdf': page(`
${logo('Booking.com', '#003580')}Confirmed

Hotel Tejo Vista, Lisbon

Rua do Alecrim 48, 1200-018 Lisboa, Portugal · +351 21 555 0170

Check-in
${plus(9).getDate()}
${plus(9).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}
from 15:00
Check-out
${plus(12).getDate()}
${plus(12).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}
until 11:00
RoomNightsPrice
Superior double, river view, breakfast included3€486.00
City tax (paid at the property)€12.00
Paid€486.00

Confirmation

Number 4892.771.433 · PIN 2047. Show this voucher at reception. Free cancellation ended ${fmt(plus(2))}.

Guest: Nora Lindqvist · 1 adultBooking.com B.V.
`), // Boarding pass BER → LIS. 'boarding-pass-ber-lis.pdf': page(`
${logo('Lufthansa', '#05164d')}Boarding pass
Berlin
BER
→
Lisbon
LIS
Flight
LH 1796
Date
${fmt(plus(9))}
Boarding
07:55
Departure
08:35
Passenger
LINDQVIST / NORA
Seat
14A
Gate
B21
Class
Economy
M1LINDQVIST/NORA E7KQ2R BERLISLH 1796 ${String(plus(9).getDate()).padStart(2, '0')}Y014A0031 100

Be at the gate 30 minutes before departure. Hand baggage: 1 piece up to 8 kg.

Booking reference E7KQ2R · Miles & More 992 004 118 221Deutsche Lufthansa AG
`), // Conference ticket. 'infra-summit-ticket.pdf': page(`
${logo('Infra Summit Lisbon', '#db2d54')}Speaker

Nora Lindqvist

Meridian Labs · Talk: "Rolling back without rolling over: safe launches for event platforms"

When
${fmt(plus(10))} to ${fmt(plus(11))}
Doors 08:30, talks from 09:30
Where
Centro de Congressos de Lisboa
Praça das Indústrias 1, Lisboa
Ticket
IS26-SPK-01827
Includes the speakers' dinner on day one and the workshop day.
Issued by Infra Summit Lda. via TitoNon-transferable
`), // Beacon launch plan (the PDF in the Projects folder). 'beacon-launch-plan-v3.pdf': page(`
${logo('Meridian Labs', '#db2d54')}Internal · v3

Beacon launch plan

Owner: Michael Torres · Engineering: Alice Johnson, Nora Lindqvist · Launch ${fmt(plus(8))}

What ships

Beacon replaces the polling notification API with a push platform: webhooks with signed deliveries, server-sent events for browsers, and a retry queue with per-tenant budgets. Customers on the current API keep working until ${nextMonth} ${year + 1}.

Timeline

DayMilestoneOwnerState
${fmt(plus(-6))}Load test at 3× peak, 48 h soakAhmeddone
${fmt(plus(1))}Go / no-go reviewAlicescheduled
${fmt(plus(3))}Rollback runbook signed offNorain review
${fmt(plus(6))}Canary: 5% of tenants, 24 hAhmed, Priyaplanned
${fmt(plus(8))}General availability, blog post, changelogRachelplanned

Risks

Webhook retries during a partial outage can double delivery volume; the budget caps that at 2× per tenant. The old API's long-poll clients see one reconnect at cutover. Both are covered in the runbook.

Meridian Labs GmbH · ConfidentialPage 1 of 1
`), // Hetzner invoice in Receipts. 'hetzner-invoice.pdf': page(`
${logo('Hetzner', '#d50c2d')}
Hetzner Online GmbH
Industriestr. 25, 91710 Gunzenhausen

Invoice R0012345678

Customer K0987654 · Meridian Labs GmbH · Period ${month} ${year}

ProductLocationAmount
Cloud server CAX31 (beacon-eu-1)Falkenstein€14.69
Cloud server CAX31 (beacon-eu-2)Helsinki€14.69
Load balancer LB11Falkenstein€5.83
Object storage, 412 GBFalkenstein€8.24
VAT 19%€8.26
Total (paid by SEPA direct debit)€51.71
Hetzner Online GmbH · VAT DE 812 871 812Page 1 of 1
`), // Quarterly report in Files. 'quarterly-report.pdf': page(`
${logo('Meridian Labs', '#db2d54')}Board update

Quarterly report

Engineering and product · prepared by Nora Lindqvist and Michael Torres

Uptime
99.97%
p95 latency
184 ms
Active tenants
1,284

Highlights

Beacon reached feature-complete and passed the 48-hour soak at three times peak traffic. The website redesign goes live with the launch. Support volume fell 18% after the status page and the new error pages shipped.

Next quarter

Beacon general availability, migration tooling for long-poll customers, and the first external audit of the delivery signing scheme.

Meridian Labs GmbH · ConfidentialPage 1 of 1
`), // Internal purchase order attached to the support thread. 'po-lumen-logistics.pdf': page(`
${logo('Lumen Logistics', '#1d4ed8')}Purchase order

PO 2026-0412

Lumen Logistics S.A. · Av. da Liberdade 110, Lisboa · to Meridian Labs GmbH

ItemQtyAmount
Meridian Platform, Business plan, annual1€11,880.00
Beacon add-on, 50 tenants1€3,600.00
Total excl. VAT€15,480.00

Please invoice to accounts@lumenlogistics.example and quote the PO number. Payment terms 30 days.

Approved: Tomás Ferreira, Head of OperationsPage 1 of 1
`), }; // PNGs rendered from HTML at 2x. const IMAGES = { // Screenshot of the Beacon metrics dashboard, attached to Ahmed's soak-test mail. 'beacon-soak-dashboard.png': { width: 1200, height: 720, html: `
Beacon · soak test, 48 h at 3× peakeu-1, eu-2 · last 48 hours
Deliveries / s42,380
p95 delivery latency184 ms
Retry budget used11%
Errors0.02%
Deliveries per second ${[0.25, 0.5, 0.75].map((f) => ``).join('')}
`, }, // Illustration draft from Daniel Cooper (the commission thread). 'book-cover-draft.png': { width: 900, height: 1200, html: `

The Lemon
Tree Year

Sofia Russo

draft 2 — bolder sun?
`, }, }; // Plain-text files in the Files app. const TEXT = { 'meeting-notes.md': `# Beacon weekly · ${fmt(plus(-1))} Attendees: Alice, Bob, Priya, Ahmed, Lisa, Nora ## Decisions - Canary stays at 5% for a full 24 h, not 12. Priya wants one night of traffic. - Webhook signing keys rotate every 90 days; the old key stays valid for 48 h. - Rachel gets the changelog draft by ${fmt(plus(4))}. ## Open - [ ] Rollback runbook: Nora, review by Bob and Ahmed - [ ] Long-poll reconnect test on the staging tenant: Lisa - [ ] Status page component for Beacon: Ahmed ## Notes Load test held at 3× peak for 48 h without a retry-budget breach. p95 184 ms. `, 'todo.txt': `(A) Rollback runbook, send to Bob and Ahmed @beacon due:${plus(3).toISOString().slice(0, 10)} (A) RSVP go/no-go review @beacon (B) Book airport train for ${fmt(plus(9))} @travel (B) Reply to Mom about the weekend @family (C) Pick up suit from the cleaner @errands x Sign DesignCo invoice 2026-089 @finance `, }; // ── Office documents ───────────────────────────────────────────────────── async function xlsxLaunchMetrics() { const wb = new ExcelJS.Workbook(); wb.creator = 'Nora Lindqvist'; const ws = wb.addWorksheet('Soak test'); ws.columns = [ { header: 'Hour', key: 'h', width: 8 }, { header: 'Deliveries/s', key: 'd', width: 14 }, { header: 'p95 ms', key: 'p', width: 10 }, { header: 'Errors %', key: 'e', width: 10 }, { header: 'Retry budget %', key: 'r', width: 16 }, ]; for (let h = 1; h <= 48; h++) { ws.addRow({ h, d: 40000 + Math.round(Math.sin(h / 3) * 2500 + (h % 7) * 300), p: 170 + (h % 5) * 4 + (h > 30 ? 6 : 0), e: Number((0.01 + (h % 4) * 0.005).toFixed(3)), r: Math.min(14, 4 + Math.round(h / 5)) }); } ws.getRow(1).font = { bold: true }; ws.views = [{ state: 'frozen', ySplit: 1 }]; const sum = wb.addWorksheet('Summary'); sum.addRows([ ['Metric', 'Value', 'Target'], ['Peak deliveries/s', 42380, 36000], ['p95 latency (ms)', 184, 250], ['Error rate', '0.02%', '< 0.1%'], ['Retry budget used', '11%', '< 50%'], ['Verdict', 'Pass', ''], ]); sum.getRow(1).font = { bold: true }; sum.columns = [{ width: 22 }, { width: 12 }, { width: 12 }]; return Buffer.from(await wb.xlsx.writeBuffer()); } async function xlsxBudget() { const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('Budget'); ws.addRow(['Category', 'Planned', 'Actual', 'Delta']); const rows = [['Cloud', 7200, 6840], ['Tooling', 2400, 2510], ['Design (DesignCo)', 5000, 5236], ['Conferences', 3000, 1870], ['Hardware', 1500, 0]]; rows.forEach(([c, p, a], i) => { const r = i + 2; ws.addRow([c, p, a, { formula: `C${r}-B${r}` }]); }); ws.addRow(['Total', { formula: 'SUM(B2:B6)' }, { formula: 'SUM(C2:C6)' }, { formula: 'SUM(D2:D6)' }]); ws.getRow(1).font = { bold: true }; ws.getRow(7).font = { bold: true }; ws.columns = [{ width: 22 }, { width: 12 }, { width: 12 }, { width: 12 }]; for (const col of ['B', 'C', 'D']) ws.getColumn(col).numFmt = '€#,##0'; return Buffer.from(await wb.xlsx.writeBuffer()); } async function docxRunbook() { const { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType } = docx; const p = (text, opts = {}) => new Paragraph({ children: [new TextRun({ text, ...opts })], spacing: { after: 120 } }); const h = (text, level = HeadingLevel.HEADING_1) => new Paragraph({ text, heading: level, spacing: { before: 240, after: 120 } }); const cell = (text, bold = false) => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold })] })] }); const steps = [ ['1', 'Freeze deploys; announce in #beacon-launch', 'On-call'], ['2', 'Flip the tenant router back to the polling API (feature flag beacon.route)', 'Nora'], ['3', 'Drain the webhook queue; retries pause automatically once the flag is off', 'Ahmed'], ['4', 'Confirm long-poll clients reconnected (dashboard: reconnects < 1%)', 'Lisa'], ['5', 'Post the status page update and the customer note', 'Rachel'], ]; const doc = new Document({ creator: 'Nora Lindqvist', title: 'Beacon rollback runbook', sections: [{ children: [ new Paragraph({ text: 'Beacon rollback runbook', heading: HeadingLevel.TITLE }), p('Meridian Labs · draft 2 · owner Nora Lindqvist · reviewers Bob Chen, Ahmed Hassan', { italics: true, color: '666666' }), h('When to roll back'), p('Error rate above 0.5% for ten minutes, p95 delivery latency above 1 s, or any retry budget breach on more than three tenants. The on-call engineer decides; nobody needs to be asked first.'), h('Steps'), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [cell('#', true), cell('Action', true), cell('Owner', true)] }), ...steps.map(([n, a, o]) => new TableRow({ children: [cell(n), cell(a), cell(o)] })), ] }), h('Expected impact'), p('Customers on the new API see deliveries pause for the drain window, at most two minutes. Nothing is lost: the queue is replayed after the fix. Long-poll clients see one reconnect.'), h('Open questions'), p('Do we keep the signing keys rotated during the rollback window? Bob to confirm with the auditor.'), ], }], }); return Buffer.from(await Packer.toBuffer(doc)); } async function docxContract() { const { Document, Packer, Paragraph, TextRun, HeadingLevel } = docx; const p = (text) => new Paragraph({ children: [new TextRun(text)], spacing: { after: 160 } }); const doc = new Document({ title: 'Service agreement draft', sections: [{ children: [ new Paragraph({ text: 'Service agreement', heading: HeadingLevel.TITLE }), p('between Meridian Labs GmbH ("Provider") and Lumen Logistics S.A. ("Customer"). Draft for review, not for signature.'), new Paragraph({ text: '1. Scope', heading: HeadingLevel.HEADING_1 }), p('The Provider operates the Meridian Platform and the Beacon notification service for the Customer under the Business plan, as described in Purchase Order 2026-0412.'), new Paragraph({ text: '2. Service levels', heading: HeadingLevel.HEADING_1 }), p('Monthly availability of 99.9%, measured at the API edge. Credits of 10% of the monthly fee per 0.1% below target, capped at 50%.'), new Paragraph({ text: '3. Term', heading: HeadingLevel.HEADING_1 }), p('Twelve months from the effective date, renewing yearly unless either party gives sixty days notice.'), ] }], }); return Buffer.from(await Packer.toBuffer(doc)); } async function pptxLaunchDeck() { const pres = new PptxGenJS(); pres.layout = 'LAYOUT_16x9'; pres.author = 'Rachel Green'; pres.title = 'Beacon launch'; const rasp = 'DB2D54'; const cover = pres.addSlide(); cover.background = { color: '0F1117' }; cover.addText('Beacon', { x: 0.6, y: 1.6, w: 8, h: 1.2, fontSize: 54, bold: true, color: 'FFFFFF', fontFace: 'Segoe UI' }); cover.addText('Real-time notifications for every Meridian tenant', { x: 0.6, y: 2.8, w: 8, h: 0.6, fontSize: 20, color: 'C7C9D1', fontFace: 'Segoe UI' }); cover.addShape(pres.ShapeType.rect, { x: 0.6, y: 3.6, w: 1.4, h: 0.08, fill: { color: rasp }, line: { color: rasp } }); cover.addText(`Launch ${fmt(plus(8))} · Meridian Labs`, { x: 0.6, y: 4.6, w: 8, h: 0.4, fontSize: 12, color: '8B8E98', fontFace: 'Segoe UI' }); const what = pres.addSlide(); what.addText('What customers get', { x: 0.6, y: 0.4, w: 8, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' }); what.addText([ { text: 'Webhooks with signed deliveries', options: { bullet: true, breakLine: true } }, { text: 'Server-sent events for browsers, no polling', options: { bullet: true, breakLine: true } }, { text: 'Retry queue with per-tenant budgets', options: { bullet: true, breakLine: true } }, { text: 'Delivery log with replay, 30 days', options: { bullet: true } }, ], { x: 0.8, y: 1.4, w: 8, h: 3, fontSize: 20, color: '3F3F46', fontFace: 'Segoe UI', paraSpaceAfter: 10 }); const num = pres.addSlide(); num.addText('Soak test, 48 hours at 3× peak', { x: 0.6, y: 0.4, w: 8.5, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' }); const tiles = [['42,380', 'deliveries / s'], ['184 ms', 'p95 latency'], ['0.02%', 'errors'], ['11%', 'retry budget']]; tiles.forEach(([v, l], i) => { const x = 0.6 + i * 2.25; num.addShape(pres.ShapeType.roundRect, { x, y: 1.6, w: 2.05, h: 1.6, fill: { color: 'F4F4F5' }, line: { color: 'E4E4E7' }, rectRadius: 0.1 }); num.addText(v, { x, y: 1.75, w: 2.05, h: 0.8, fontSize: 28, bold: true, color: rasp, align: 'center', fontFace: 'Segoe UI' }); num.addText(l, { x, y: 2.5, w: 2.05, h: 0.5, fontSize: 13, color: '71717A', align: 'center', fontFace: 'Segoe UI' }); }); num.addText('Verdict: pass. Canary at 5% of tenants for 24 h, then general availability.', { x: 0.6, y: 3.7, w: 8.5, h: 0.6, fontSize: 16, color: '3F3F46', fontFace: 'Segoe UI' }); const plan = pres.addSlide(); plan.addText('Rollout', { x: 0.6, y: 0.4, w: 8, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' }); plan.addTable([ [{ text: 'Day', options: { bold: true } }, { text: 'Milestone', options: { bold: true } }, { text: 'Owner', options: { bold: true } }], [fmt(plus(1)), 'Go / no-go review', 'Alice'], [fmt(plus(3)), 'Rollback runbook signed off', 'Nora'], [fmt(plus(6)), 'Canary, 5% of tenants', 'Ahmed, Priya'], [fmt(plus(8)), 'General availability', 'Rachel'], ], { x: 0.6, y: 1.4, w: 8.8, fontSize: 14, fontFace: 'Segoe UI', color: '3F3F46', border: { type: 'solid', color: 'E4E4E7', pt: 1 }, fill: { color: 'FFFFFF' } }); return Buffer.from(await pres.write({ outputType: 'nodebuffer' })); } // ── Run ────────────────────────────────────────────────────────────────── const browser = await chromium.launch(); const ctx = await browser.newContext({ deviceScaleFactor: 2 }); const pg = await ctx.newPage(); for (const [name, html] of Object.entries(DOCS)) { await pg.setContent(html, { waitUntil: 'load' }); write(name, await pg.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true })); } for (const [name, { width, height, html }] of Object.entries(IMAGES)) { await pg.setViewportSize({ width, height }); await pg.setContent(html, { waitUntil: 'load' }); write(name, await pg.screenshot({ type: 'png', clip: { x: 0, y: 0, width, height } })); } await browser.close(); for (const [name, text] of Object.entries(TEXT)) write(name, Buffer.from(text, 'utf8')); write('beacon-soak-metrics.xlsx', await xlsxLaunchMetrics()); write('budget-2026.xlsx', await xlsxBudget()); if (docx) { write('beacon-rollback-runbook.docx', await docxRunbook()); write('service-agreement-draft.docx', await docxContract()); } else console.log('docx not available: Word files kept as they are'); if (PptxGenJS) write('beacon-launch-deck.pptx', await pptxLaunchDeck()); else console.log('pptxgenjs not available: deck kept as it is'); for (const w of written) console.log(' +', w); console.log(`output: ${OUT}`);