#!/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) => `${name}`;
// 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
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
Room
Nights
Price
Superior double, river view, breakfast included
3
€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(`
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
Day
Milestone
Owner
State
${fmt(plus(-6))}
Load test at 3× peak, 48 h soak
Ahmed
done
${fmt(plus(1))}
Go / no-go review
Alice
scheduled
${fmt(plus(3))}
Rollback runbook signed off
Nora
in review
${fmt(plus(6))}
Canary: 5% of tenants, 24 h
Ahmed, Priya
planned
${fmt(plus(8))}
General availability, blog post, changelog
Rachel
planned
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(`
Customer K0987654 · Meridian Labs GmbH · Period ${month} ${year}
Product
Location
Amount
Cloud server CAX31 (beacon-eu-1)
Falkenstein
€14.69
Cloud server CAX31 (beacon-eu-2)
Helsinki
€14.69
Load balancer LB11
Falkenstein
€5.83
Object storage, 412 GB
Falkenstein
€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
Item
Qty
Amount
Meridian Platform, Business plan, annual
1
€11,880.00
Beacon add-on, 50 tenants
1
€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
`,
},
// 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}`);