create-pmos 0.530.0 → 0.532.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -41
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/changelog.json +26 -0
- package/workspace/guide/reports.md +5 -1
- package/workspace/lib/report-cli.js +26 -6
- package/workspace/lib/report-engine.js +77 -20
- package/workspace/lib/report-export.js +155 -3
- package/workspace/lib/report-library.js +134 -0
- package/workspace/lib/report-pyramid.js +2 -0
- package/workspace/lib/report-slides.js +580 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-vw.css +1 -0
- package/workspace/public/pm/premium-report-read.js +24 -3
- package/workspace/skills/report-write.skill +4 -1
- package/workspace/test-lists/report-slides.json +9 -0
|
@@ -220,14 +220,15 @@ function pyramidSlideXml(s, L) {
|
|
|
220
220
|
const E = (v) => Math.round(v * PX);
|
|
221
221
|
const fill = (c) => (c ? `<a:solidFill><a:srgbClr val="${hex(c)}"/></a:solidFill>` : '<a:noFill/>');
|
|
222
222
|
const ln = (c, w, dash) => (c ? `<a:ln w="${Math.round((w || 1) * PX)}">${fill(c)}${dash ? '<a:prstDash val="dash"/>' : ''}</a:ln>` : '<a:ln><a:noFill/></a:ln>');
|
|
223
|
-
const shape = (x, y, w, h, o = {}) => { id++; out.push(`<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${o.name || 'Shape ' + id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${o.flip ? ' flipV="1"' : ''}><a:off x="${E(x)}" y="${E(y)}"/><a:ext cx="${E(Math.max(w, 0.5))}" cy="${E(Math.max(h, 0.5))}"/></a:xfrm><a:prstGeom prst="${o.geom || 'rect'}"><a:
|
|
224
|
-
const run = (t, o) => `<a:r><a:rPr lang="en-GB" sz="${Math.round((o.size || 18) * 75)}"${o.bold ? ' b="1"' : ''} dirty="0">${fill(o.color || L.ink)}<a:latin typeface="${xml(L.font)}"/></a:rPr><a:t>${xml(t)}</a:t></a:r>`;
|
|
223
|
+
const shape = (x, y, w, h, o = {}) => { id++; out.push(`<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${o.name || 'Shape ' + id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${o.flip ? ' flipV="1"' : ''}><a:off x="${E(x)}" y="${E(y)}"/><a:ext cx="${E(Math.max(w, 0.5))}" cy="${E(Math.max(h, 0.5))}"/></a:xfrm><a:prstGeom prst="${o.geom || 'rect'}">${o.adj != null ? `<a:avLst><a:gd name="adj" fmla="val ${o.adj}"/></a:avLst>` : '<a:avLst/>'}</a:prstGeom>${fill(o.fill)}${ln(o.line, o.lw, o.dash)}</p:spPr></p:sp>`); };
|
|
224
|
+
const run = (t, o) => `<a:r><a:rPr lang="en-GB" sz="${Math.round((o.size || 18) * 75)}"${o.bold ? ' b="1"' : ''} dirty="0">${fill(o.color || (L && L.ink) || "#2A2E35")}<a:latin typeface="${xml(o.font || (L && L.font) || "Calibri")}" pitchFamily="34" charset="0"/><a:cs typeface="${xml(o.font || (L && L.font) || "Calibri")}" pitchFamily="34" charset="0"/></a:rPr><a:t>${xml(t)}</a:t></a:r>`;
|
|
225
225
|
/** a text box; paras = [string | [[text, opts], …]] */
|
|
226
226
|
const text = (x, y, w, h, paras, o = {}) => {
|
|
227
227
|
id++;
|
|
228
228
|
const ps = (Array.isArray(paras) ? paras : [paras]).map((p) => `<a:p><a:pPr algn="${o.align || 'l'}"/>${(Array.isArray(p) ? p : [[p, {}]]).map(([t, ro]) => run(t, { ...o, ...ro })).join('')}</a:p>`).join('');
|
|
229
229
|
out.push(`<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${o.name || 'Text ' + id}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${E(x)}" y="${E(y)}"/><a:ext cx="${E(w)}" cy="${E(h)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr><p:txBody><a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="${o.anchor || 't'}"><a:normAutofit/></a:bodyPr><a:lstStyle/>${ps}</p:txBody></p:sp>`);
|
|
230
230
|
};
|
|
231
|
+
if (s.layout) { b2Parts(s, { shape, text }); return out; }
|
|
231
232
|
const navy = s.kind === 'title';
|
|
232
233
|
if (navy) shape(0, 0, 1280, 720, { fill: L.accent, name: 'Background' });
|
|
233
234
|
// the footer: the source of every number on the slide, and the page number
|
|
@@ -377,6 +378,157 @@ function pyramidSlideXml(s, L) {
|
|
|
377
378
|
return out;
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
/** A slide in the report library's frame (B2, lib/report-slides.js): the panel, the eyebrow, the heading, the subheading, the layout's lines and the
|
|
382
|
+
* insight column, and the footer. The PowerPoint file is the first state of the slide: no control, nothing ticked. */
|
|
383
|
+
function b2Parts(s, { shape, text }) {
|
|
384
|
+
const RS = require('./report-slides.js');
|
|
385
|
+
const C = { ink: '#2A2E35', ink2: '#4A4F58', ink3: '#646A74', hair: '#E3E4E6', blue: '#2F5FB3', ground: '#F2F4F7', well: '#F4F3F0', track: '#ECEAE6', bluebg: '#EEF5FC' };
|
|
386
|
+
// the tones as the slides draw them: a dot or a bar fill, and a pill's ground and ink
|
|
387
|
+
const DOTC = { ok: '#5E9C6A', watch: '#D4A13A', late: '#D9773F', bad: '#D0473E', info: '#4894EA', none: '#B9BDC4' };
|
|
388
|
+
const PILLC = { ok: ['#EDF5EF', '#3E6E49'], watch: ['#FBF4E4', '#8A5A00'], late: ['#FBF0EA', '#A4461A'], bad: ['#FBEDEC', '#A8322A'], info: ['#EEF5FC', '#2F5FB3'], none: ['#F4F3F0', '#4A4F58'] };
|
|
389
|
+
const SPANC = { ok: ['#EDF5EF', '#3E6E49'], watch: ['#FBF4E4', '#8A5A00'], late: ['#FBF4E4', '#8A5A00'], bad: ['#FBEDEC', '#A8322A'], info: ['#E6E8EC', '#4A4F58'], none: ['#D9DCE1', '#4A4F58'] };
|
|
390
|
+
const tn = (t) => (RS.TONES.includes(t) ? t : 'none');
|
|
391
|
+
const o = (x) => ({ font: 'Plus Jakarta Sans', ...x });
|
|
392
|
+
const round = (r, w, h) => Math.round((r / Math.min(w, h)) * 100000);
|
|
393
|
+
shape(0, 0, 1280, 720, { fill: C.ground, name: 'Ground' });
|
|
394
|
+
shape(10, 10, 1260, 700, { fill: '#FFFFFF', geom: 'roundRect', adj: round(28, 1260, 700), name: 'Panel' });
|
|
395
|
+
let y = 32;
|
|
396
|
+
if (s.eyebrow) { text(38, y, 1000, 20, s.eyebrow, o({ size: 13, bold: true, color: C.ink3 })); y += 22; }
|
|
397
|
+
text(38, y, 1000, 64, s.title || '', o({ size: 26, bold: true, color: C.ink, name: 'Title' })); y += s.title && s.title.length > 70 ? 66 : 36;
|
|
398
|
+
if (s.sub) { text(38, y, 900, 22, s.sub, o({ size: 15, color: C.ink3 })); y += 26; }
|
|
399
|
+
y += 10; shape(38, y, 1204, 1, { fill: C.hair, name: 'Hairline' }); y += 16;
|
|
400
|
+
const ins = s.insight && (s.insight.head || s.insight.text) ? s.insight : null;
|
|
401
|
+
const W = ins ? 830 : 1204; const X = 38; const BOT = 640;
|
|
402
|
+
const d = s.data || {};
|
|
403
|
+
const dot = (x, yy, t) => shape(x, yy, 9, 9, { geom: 'ellipse', fill: DOTC[tn(t)] });
|
|
404
|
+
const pill = (x, yy, t, label, w) => { const [bg, ink] = PILLC[tn(t)]; const ww = w || Math.min(160, 18 + label.length * 7); shape(x, yy, ww, 22, { geom: 'roundRect', adj: 50000, fill: bg }); text(x, yy + 3, ww, 16, label, o({ size: 11, bold: true, color: ink, align: 'ctr' })); return ww; };
|
|
405
|
+
const bar = (x, yy, w, share, target, t, h = 6) => { shape(x, yy, w, h, { geom: 'roundRect', adj: 50000, fill: C.track }); if (share != null) shape(x, yy, Math.max(1, w * Math.max(0, Math.min(1, share))), h, { geom: 'roundRect', adj: 50000, fill: t ? DOTC[tn(t)] : '#8C929B' }); if (target != null) shape(x + w * target - 1, yy - 3, 2, h + 6, { fill: C.ink }); };
|
|
406
|
+
const rule = (yy, x = X, w = W) => shape(x, yy, w, 1, { fill: C.hair });
|
|
407
|
+
const band = (yy, h = 30) => shape(X, yy, W, h, { geom: 'roundRect', adj: round(10, W, h), fill: C.well });
|
|
408
|
+
const rowsAt = (n, h) => Array.from({ length: n }, (_, i) => y + i * h);
|
|
409
|
+
const plain = () => { const lines = RS.linesOf(s.layout, d); lines.slice(0, Math.floor((BOT - y) / 40)).forEach((l, i) => { const yy = y + i * 40; if (i) rule(yy - 4); text(X, yy + 6, W, 28, l, o({ size: 15, color: C.ink })); }); };
|
|
410
|
+
const L = s.layout;
|
|
411
|
+
if (L === 'tiles') {
|
|
412
|
+
const n = d.tiles.length; const cw = W / n;
|
|
413
|
+
d.tiles.forEach((t, i) => { const x = X + i * cw + (i ? 22 : 0); const w = cw - (i ? 44 : 22); if (i) shape(X + i * cw, y, 1, 150, { fill: C.hair });
|
|
414
|
+
text(x, y, w, 20, t.label, o({ size: 13, color: C.ink2 })); text(x, y + 22, w, 36, t.value, o({ size: 26, bold: true, color: C.ink }));
|
|
415
|
+
if (t.note) text(x, y + 62, w, 20, t.note, o({ size: 13, color: C.blue })); if (t.share != null) bar(x, y + 92, w, t.share, t.target, t.tone); });
|
|
416
|
+
} else if (L === 'ledger' || L === 'minutes' || L === 'ranked') {
|
|
417
|
+
const rows = d.rows; const h = 58;
|
|
418
|
+
const max = L === 'minutes' ? Math.max(1, ...rows.map((r) => r.value || 0)) : 1;
|
|
419
|
+
rowsAt(rows.length, h).forEach((yy, i) => { const r = rows[i]; rule(yy);
|
|
420
|
+
if (L === 'ledger') { dot(X, yy + 24, r.tone); text(X + 16, yy + 18, 200, 22, r.name, o({ size: 15, bold: true, color: C.ink })); text(X + 228, yy + 16, 76, 26, r.value, o({ size: 18, bold: true, color: C.ink }));
|
|
421
|
+
const bw = W - 228 - 94 - 150; if (r.share != null) bar(X + 322, yy + 22, bw, r.share, r.target, r.tone); if (r.target_text) text(X + 322, yy + 32, bw, 18, r.target_text, o({ size: 12, color: C.ink3 })); if (r.verdict) pill(X + W - 130, yy + 18, r.tone, r.verdict, 130); }
|
|
422
|
+
else if (L === 'minutes') { text(X, yy + 10, 250, 22, r.name, o({ size: 15, bold: true, color: C.ink })); if (r.note) text(X, yy + 32, 250, 18, r.note, o({ size: 12, color: C.ink3 })); bar(X + 270, yy + 24, W - 380, (r.value || 0) / max, null, 'info', 10); text(X + W - 100, yy + 16, 100, 24, r.text || `${r.value} ${d.unit || ''}`.trim(), o({ size: 18, bold: true, color: C.ink, align: 'r' })); }
|
|
423
|
+
else { shape(X, yy + 13, 32, 32, { geom: 'ellipse', fill: C.well }); text(X, yy + 20, 32, 18, String(i + 1), o({ size: 13, bold: true, color: C.ink2, align: 'ctr' })); text(X + 44, yy + 18, W - 470, 22, r.name, o({ size: 15, bold: true, color: C.ink }));
|
|
424
|
+
if (r.a != null) { text(X + W - 410, yy + 8, 190, 16, d.a || 'Impact', o({ size: 12, color: C.ink3 })); bar(X + W - 410, yy + 30, 190, r.a, null, 'info'); }
|
|
425
|
+
if (r.b != null) { text(X + W - 190, yy + 8, 190, 16, d.b || 'Effort', o({ size: 12, color: C.ink3 })); bar(X + W - 190, yy + 30, 190, r.b, null, null); } } });
|
|
426
|
+
} else if (L === 'timeline') {
|
|
427
|
+
const today = RS.timelineToday(d, { today: null });
|
|
428
|
+
const w = RS.timelineView(d, today); const nm = 200; const tw = W - nm; const n = w.cols.length;
|
|
429
|
+
band(y, 30); text(X + 12, y + 7, nm - 12, 18, 'Initiative', o({ size: 13, color: C.ink3 }));
|
|
430
|
+
w.cols.forEach((c, i) => text(X + nm + (i * tw) / n + 8, y + 7, tw / n - 8, 18, c, o({ size: 13, color: C.ink3 })));
|
|
431
|
+
const h = 46; const y0 = y + 34; const tx = ((Date.parse(today + 'T00:00:00Z') - w.a) / (w.b + 86400000 - w.a)) * tw;
|
|
432
|
+
d.rows.forEach((r, i) => { const yy = y0 + i * h; if (yy + h > BOT) return; if (i) rule(yy);
|
|
433
|
+
text(X, yy + 14, nm - 14, 20, r.name, o({ size: 13, bold: true, color: C.ink }));
|
|
434
|
+
const g = RS.timelinePlace(r, w, today); const [bg, ink] = SPANC[tn(r.tone)];
|
|
435
|
+
if (g.out) { const lw = Math.min(tw, 30 + g.label.length * 6.5); const x = g.out === 'before' ? X + nm : X + nm + tw - lw; shape(x, yy + 13, lw, 20, { geom: 'roundRect', adj: round(9, lw, 20), fill: bg }); text(x + 8, yy + 15, lw - 16, 16, g.label, o({ size: 11, bold: true, color: ink })); }
|
|
436
|
+
else { const x = X + nm + (g.left / 100) * tw; const bw = (g.width / 100) * tw; shape(x, yy + 10, bw, 26, { geom: 'roundRect', adj: round(9, bw, 26), fill: bg });
|
|
437
|
+
if (g.inside) text(x + 8, yy + 15, bw - 16, 16, g.label, o({ size: 11, bold: true, color: ink }));
|
|
438
|
+
else { const right = g.left + g.width > 70; const lw = 30 + g.label.length * 6.5; text(right ? x - lw - 6 : x + bw + 6, yy + 15, lw, 16, g.label, o({ size: 11, bold: true, color: C.ink2, align: right ? 'r' : 'l' })); } } });
|
|
439
|
+
if (tx >= 0 && tx <= tw) shape(X + nm + tx, y0, 2, Math.min(d.rows.length * h, BOT - y0), { fill: '#4894EA', name: 'Today' });
|
|
440
|
+
} else if (L === 'risks') {
|
|
441
|
+
const cols = [0.35, 0.44]; band(y, 30);
|
|
442
|
+
text(X + 12, y + 7, W * cols[0], 18, 'Item and owner', o({ size: 13, color: C.ink3 })); text(X + W * cols[0] + 14, y + 7, W * cols[1], 18, 'Next step', o({ size: 13, color: C.ink3 })); text(X + W * (cols[0] + cols[1]) + 14, y + 7, 120, 18, 'Level', o({ size: 13, color: C.ink3 }));
|
|
443
|
+
d.rows.forEach((r, i) => { const yy = y + 36 + i * 62; if (i) rule(yy); dot(X, yy + 16, r.tone); text(X + 16, yy + 10, W * cols[0] - 16, 22, r.name, o({ size: 15, bold: true, color: C.ink }));
|
|
444
|
+
const sm = [r.owner, r.why].filter(Boolean).join(' · '); if (sm) text(X + 16, yy + 32, W * cols[0] - 16, 18, sm, o({ size: 12, color: C.ink3 }));
|
|
445
|
+
text(X + W * cols[0] + 14, yy + 12, W * cols[1] - 14, 40, r.move || '', o({ size: 15, color: C.ink })); if (r.level) pill(X + W * (cols[0] + cols[1]) + 14, yy + 12, r.tone, r.level); });
|
|
446
|
+
} else if (L === 'actions') {
|
|
447
|
+
d.rows.forEach((r, i) => { const yy = y + i * 54; rule(yy); shape(X, yy + 13, 28, 28, { geom: 'roundRect', adj: round(9, 28, 28), line: '#5E9C6A', lw: 2, name: 'Tick box' });
|
|
448
|
+
text(X + 42, yy + 16, W - 42 - 180, 24, r.text, o({ size: 18, bold: true, color: C.ink }));
|
|
449
|
+
if (r.who) { shape(X + W - 160, yy + 9, 36, 36, { geom: 'ellipse', fill: C.bluebg }); text(X + W - 160, yy + 19, 36, 16, r.who.split(/\s+/).slice(0, 2).map((x) => x[0].toUpperCase()).join(''), o({ size: 12, bold: true, color: C.blue, align: 'ctr' })); }
|
|
450
|
+
if (r.due) text(X + W - 116, yy + 18, 116, 18, RS.dmy(r.due), o({ size: 13, color: C.ink3, align: 'r' })); });
|
|
451
|
+
const yy = y + d.rows.length * 54 + 10; shape(X, yy, W, 40, { geom: 'roundRect', adj: round(12, W, 40), fill: C.bluebg }); text(X + 14, yy + 11, W - 28, 20, `✦ ${d.rows.length} left, the earliest is due first`, o({ size: 15, color: C.ink2 }));
|
|
452
|
+
} else if (L === 'decisions') {
|
|
453
|
+
d.rows.forEach((r, i) => { const yy = y + i * 86; shape(X, yy, W, 72, { geom: 'roundRect', adj: round(12, W, 72), fill: C.bluebg });
|
|
454
|
+
text(X + 18, yy + 14, 20, 22, '✦', o({ size: 15, bold: true, color: C.blue })); text(X + 40, yy + 12, W - 300, 26, r.question, o({ size: 18, bold: true, color: C.ink }));
|
|
455
|
+
if (r.pick) text(X + 40, yy + 40, W - 300, 20, (i ? '' : '✦ ') + r.pick, o({ size: 15, color: C.ink3 }));
|
|
456
|
+
const wb = [r.who, r.by ? 'by ' + RS.dmy(r.by) : ''].filter(Boolean).join(' · '); if (wb) text(X + W - 250, yy + 26, 232, 18, wb, o({ size: 12, color: C.ink3, align: 'r' })); });
|
|
457
|
+
} else if (L === 'sources') {
|
|
458
|
+
const read = d.read || []; const miss = d.missing || []; const half = read.length && miss.length ? (W - 37) / 2 : W;
|
|
459
|
+
const side = (x, label, items, f) => { text(x, y, half, 18, label, o({ size: 13, bold: true, color: C.ink3 })); items.forEach((it, i) => { const yy = y + 26 + i * 50; rule(yy, x, half); f(it, x, yy); }); };
|
|
460
|
+
if (read.length) side(X, 'Read', read, (r, x, yy) => { text(x, yy + 14, half - 220, 20, r.name, o({ size: 15, bold: true, color: C.ink })); if (r.at) text(x + half - 210, yy + 16, 120, 18, RS.dmy(r.at), o({ size: 13, color: C.ink3, align: 'r' })); pill(x + half - 70, yy + 13, 'ok', 'Read', 70); });
|
|
461
|
+
if (miss.length) { const x = read.length ? X + half + 37 : X; if (read.length) shape(X + half + 18, y, 1, 26 + miss.length * 50, { fill: C.hair }); side(x, 'Not known yet', miss, (m, xx, yy) => { text(xx, yy + 8, half - 110, 20, m.name, o({ size: 15, bold: true, color: C.ink })); if (m.why) text(xx, yy + 28, half - 110, 18, m.why, o({ size: 12, color: C.ink3 })); pill(xx + half - 96, yy + 13, 'none', 'no source', 96); }); }
|
|
462
|
+
} else if (L === 'heatmap') {
|
|
463
|
+
const n = d.cols.length; const nw = 150; const tw = has(d.total) ? 110 : 0; const cw = (W - nw - tw) / n; const h = Math.min(64, (BOT - y - 30) / d.rows.length);
|
|
464
|
+
d.cols.forEach((c, i) => text(X + nw + i * cw, y, cw, 18, c, o({ size: 13, bold: true, color: C.ink3, align: 'ctr' }))); if (tw) text(X + W - tw, y, tw, 18, d.total, o({ size: 13, bold: true, color: C.ink3, align: 'r' }));
|
|
465
|
+
d.rows.forEach((r, k) => { const yy = y + 28 + k * h; text(X, yy + h / 2 - 12, nw - 10, 24, r.name, o({ size: 18, bold: true, color: C.ink }));
|
|
466
|
+
for (let i = 0; i < n; i++) { const c = (r.cells || [])[i]; const [bg, ink] = c ? PILLC[tn(c.tone)] : [C.well, C.ink3]; shape(X + nw + i * cw + 5, yy + 5, cw - 10, h - 10, { geom: 'roundRect', adj: round(14, cw - 10, h - 10), fill: bg }); text(X + nw + i * cw + 5, yy + h / 2 - 10, cw - 10, 20, c ? c.text : '—', o({ size: 15, bold: !!c, color: ink, align: 'ctr' })); }
|
|
467
|
+
if (tw) text(X + W - tw, yy + h / 2 - 12, tw, 24, r.total || '', o({ size: 18, bold: true, color: C.ink, align: 'r' })); });
|
|
468
|
+
} else if (L === 'schedule') {
|
|
469
|
+
const n = d.cols.length; const nm = 200; const tw = W - nm; band(y, 30); text(X + 12, y + 7, nm - 12, 18, 'Work', o({ size: 13, color: C.ink3 }));
|
|
470
|
+
d.cols.forEach((c, i) => text(X + nm + (i * tw) / n + 8, y + 7, tw / n - 8, 18, c, o({ size: 13, color: C.ink3 })));
|
|
471
|
+
d.rows.forEach((r, i) => { const yy = y + 34 + i * 46; if (i) rule(yy); text(X, yy + 14, nm - 14, 20, r.name, o({ size: 13, bold: true, color: C.ink })); const [bg, ink] = SPANC[tn(r.tone)]; const x = X + nm + (r.from * tw) / n; const bw = ((r.to - r.from + 1) * tw) / n;
|
|
472
|
+
shape(x, yy + 10, bw, 26, { geom: 'roundRect', adj: round(9, bw, 26), fill: bg }); if (r.text) text(x + 8, yy + 15, bw - 16, 16, r.text, o({ size: 11, bold: true, color: ink })); });
|
|
473
|
+
} else if (L === 'donut' || L === 'lists' || L === 'compare') {
|
|
474
|
+
if (L === 'donut') d.parts.forEach((p, i) => { const yy = y + i * 54; if (i) rule(yy); shape(X, yy + 20, 14, 14, { geom: 'ellipse', fill: RS.RING[tn(p.tone)] }); text(X + 26, yy + 12, 56, 30, String(p.n), o({ size: 26, bold: true, color: C.ink })); text(X + 90, yy + 10, W - 90, 22, p.label, o({ size: 15, bold: true, color: C.ink })); if (p.names) text(X + 90, yy + 30, W - 90, 18, p.names, o({ size: 12, color: C.ink3 })); });
|
|
475
|
+
else { const half = (W - 37) / 2; [d.left, d.right].forEach((c, k) => { const x = X + k * (half + 37); if (k) shape(X + half + 18, y, 1, BOT - y - 10, { fill: C.hair }); text(x, y, half, 18, c.label || '', o({ size: 13, bold: true, color: C.ink3 }));
|
|
476
|
+
c.items.forEach((it, i) => { const yy = y + 26 + i * 58; rule(yy, x, half);
|
|
477
|
+
if (L === 'lists') { dot(x, yy + 22, c.tone || (k ? 'bad' : 'ok')); text(x + 18, yy + 14, half - 18, 24, it.text, o({ size: 18, bold: true, color: C.ink })); if (it.detail) text(x + 18, yy + 38, half - 18, 18, it.detail, o({ size: 12, color: C.ink3 })); }
|
|
478
|
+
else { text(x, yy + 8, 120, 30, it.value, o({ size: 26, bold: true, color: C.ink })); text(x + 130, yy + 14, half - 130, 22, it.name || '', o({ size: 15, bold: true, color: C.ink })); if (it.share != null) bar(x, yy + 46, half, it.share, it.target, it.tone); } }); }); }
|
|
479
|
+
} else if (L === 'agenda' || L === 'next') {
|
|
480
|
+
let x0 = X; let ww = W;
|
|
481
|
+
if (L === 'next' && d.date) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d.date); const lt = RS.longDate(d.date).split(' '); text(X, y, 240, 18, `${lt[2]} · ${lt[0]}`, o({ size: 13, bold: true, color: C.ink3 })); text(X, y + 22, 240, 36, String(+m[3]), o({ size: 26, bold: true, color: C.ink })); if (d.time) text(X, y + 62, 240, 24, d.time, o({ size: 18, bold: true, color: C.blue })); shape(X + 260, y, 1, BOT - y - 10, { fill: C.hair }); x0 = X + 288; ww = W - 288; text(x0, y, ww, 18, 'Draft agenda', o({ size: 13, bold: true, color: C.ink3 })); }
|
|
482
|
+
const rows = L === 'agenda' ? d.rows : d.items; const y1 = L === 'next' ? y + 26 : y; const h = L === 'agenda' ? Math.min(66, (BOT - y1) / rows.length) : 50;
|
|
483
|
+
rows.forEach((r, i) => { const yy = y1 + i * h; rule(yy, x0, ww);
|
|
484
|
+
if (L === 'agenda') { text(x0, yy + h / 2 - 16, 44, 32, String(i + 1), o({ size: 26, bold: true, color: '#B9CDEB' })); text(x0 + 66, yy + (r.text ? h / 2 - 22 : h / 2 - 12), ww - 66, 24, r.title, o({ size: 18, bold: true, color: C.ink })); if (r.text) text(x0 + 66, yy + h / 2 + 2, ww - 66, 20, r.text, o({ size: 15, color: C.ink2 })); }
|
|
485
|
+
else { if (r.length) text(x0, yy + 16, 70, 18, r.length, o({ size: 13, color: C.ink3 })); text(x0 + 80, yy + 13, ww - 80, 24, r.text, o({ size: 18, bold: true, color: C.ink })); } });
|
|
486
|
+
} else if (L === 'overview' || L === 'people' || L === 'quotes' || L === 'statement' || L === 'focus') {
|
|
487
|
+
let yy = y;
|
|
488
|
+
if (L === 'overview' && d.lead) { shape(X, yy, W, 60, { geom: 'roundRect', adj: round(16, W, 60), fill: C.well }); shape(X + 12, yy + 8, 44, 44, { geom: 'ellipse', fill: '#4C8FE0' }); text(X + 68, yy + 18, W - 80, 24, d.lead, o({ size: 15, color: C.ink2 })); yy += 82; }
|
|
489
|
+
if (L === 'statement') { text(X, yy, Math.min(W, 900), 150, d.text, o({ size: 26, bold: true, color: C.ink })); yy = BOT - 110; }
|
|
490
|
+
if (L === 'focus' && d.gate) { shape(X, yy, W, 92, { geom: 'roundRect', adj: round(12, W, 92), fill: C.bluebg }); text(X + 22, yy + 20, 24, 30, '✦', o({ size: 18, bold: true, color: C.blue })); text(X + 48, yy + 16, W - 70, 32, d.gate.title, o({ size: 26, bold: true, color: C.ink })); if (d.gate.text) text(X + 48, yy + 54, W - 70, 22, d.gate.text, o({ size: 15, color: C.ink3 })); yy += 116; }
|
|
491
|
+
const items = L === 'overview' ? d.cols : L === 'people' ? d.people : L === 'quotes' ? d.quotes : L === 'statement' ? (d.figures || []) : d.steps;
|
|
492
|
+
const n = Math.max(1, items.length); const cw = (W + (L === 'focus' ? 18 : 0)) / n;
|
|
493
|
+
const SH = ['#9FB8E0', '#6F92DC', '#2B55C9', '#1E449B'];
|
|
494
|
+
items.forEach((it, i) => { const x = X + i * cw + (L === 'focus' ? 0 : i ? 24 : 0); const w = cw - (L === 'focus' ? 18 : i ? 48 : 24);
|
|
495
|
+
if (L !== 'focus' && i) shape(X + i * cw, yy, 1, L === 'statement' ? 90 : 200, { fill: C.hair });
|
|
496
|
+
if (L === 'overview') { text(x, yy, 60, 50, String(i + 1), o({ size: 44, bold: true, color: '#B9CDEB' })); text(x, yy + 62, w, 26, it.head, o({ size: 18, bold: true, color: C.ink })); if (it.text) text(x, yy + 92, w, 80, it.text, o({ size: 15, color: C.ink2 })); }
|
|
497
|
+
else if (L === 'people') { shape(x, yy, 84, 84, { geom: 'ellipse', fill: C.bluebg }); text(x, yy + 30, 84, 24, it.initials || it.name.split(/\s+/).slice(0, 2).map((z) => z[0].toUpperCase()).join(''), o({ size: 18, bold: true, color: C.blue, align: 'ctr' })); text(x, yy + 96, w, 26, it.name, o({ size: 18, bold: true, color: C.ink })); if (it.role) pill(x, yy + 126, 'info', it.role); if (it.owns) text(x, yy + 158, w, 60, it.owns, o({ size: 15, color: C.ink2 })); }
|
|
498
|
+
else if (L === 'quotes') { text(x, yy, 40, 40, '“', o({ size: 44, bold: true, color: '#B9CDEB' })); text(x, yy + 44, w, 150, it.text, o({ size: 18, color: C.ink })); const wa = [it.who, it.at].filter(Boolean).join(' · '); if (wa) pill(x, yy + 200, 'none', wa); }
|
|
499
|
+
else if (L === 'statement') { text(x, yy, w, 36, it.value, o({ size: 26, bold: true, color: RS.FILL[tn(it.tone)] === RS.FILL.none ? C.ink : RS.FILL[tn(it.tone)] })); text(x, yy + 40, w, 22, it.label, o({ size: 15, bold: true, color: C.ink })); if (it.source) text(x, yy + 64, w, 18, it.source, o({ size: 12, color: C.ink3 })); }
|
|
500
|
+
else { shape(x, yy, w, 3, { fill: SH[i + (4 - items.length)] || SH[3] }); text(x, yy + 14, w, 18, it.label, o({ size: 13, bold: true, color: C.ink3 })); text(x, yy + 36, w, 26, it.title, o({ size: 18, bold: true, color: C.ink })); if (it.text) text(x, yy + 66, w, 60, it.text, o({ size: 15, color: C.ink2 })); } });
|
|
501
|
+
if (L === 'quotes' && d.agreed) { const ay = yy + 250; shape(X, ay, W, 44, { geom: 'roundRect', adj: round(12, W, 44), fill: C.bluebg }); text(X + 14, ay + 12, W - 28, 20, [[['✦ ', o({ color: C.blue, bold: true })], ['Agreed: ', o({ bold: true, color: C.ink })], [d.agreed, o({ color: C.ink2 })]]], o({ size: 15 })); }
|
|
502
|
+
} else if (L === 'bubbles') {
|
|
503
|
+
const px = X + 30; const pw = W - 70; const ph = BOT - y - 50; const py = y + 22;
|
|
504
|
+
text(X, y, 300, 18, `${d.y || 'Outcome'} ↑`, o({ size: 13, bold: true, color: C.ink3 })); shape(px, py, 1, ph, { fill: C.hair }); shape(px, py + ph, pw, 1, { fill: C.hair }); text(X + W - 300, py + ph + 8, 300, 18, `${d.x || 'More spend'} →`, o({ size: 13, bold: true, color: C.ink3, align: 'r' }));
|
|
505
|
+
d.points.forEach((p) => { const sz = 48 + 60 * (p.size == null ? 0.4 : p.size); const cx = px + pw * p.x; const cy = py + ph * (1 - p.y); shape(cx - sz / 2, cy - sz / 2, sz, sz, { geom: 'ellipse', fill: RS.FILL[tn(p.tone)] }); text(cx - sz / 2, cy - 9, sz, 18, p.label, o({ size: 12, bold: true, color: '#FFFFFF', align: 'ctr' })); });
|
|
506
|
+
} else if (L === 'network') {
|
|
507
|
+
const ids = d.nodes.map((n) => n.id); const depth = new Map(ids.map((z) => [z, 0]));
|
|
508
|
+
for (let k = 0; k < ids.length; k++) for (const [a, b] of d.edges) if (depth.get(b) < depth.get(a) + 1) depth.set(b, Math.min(depth.get(a) + 1, ids.length));
|
|
509
|
+
const cols = Math.max(...depth.values()) + 1; const byCol = Array.from({ length: cols }, () => []); for (const n of d.nodes) byCol[depth.get(n.id)].push(n);
|
|
510
|
+
const nw = 176; const nh = 56; const H = BOT - y - 10; const gx = cols > 1 ? (W - nw) / (cols - 1) : 0; const pos = new Map();
|
|
511
|
+
byCol.forEach((list, c) => list.forEach((n, i) => pos.set(n.id, { x: X + c * gx, y: y + ((i + 1) * H) / (list.length + 1) - nh / 2 })));
|
|
512
|
+
for (const [a, b] of d.edges) { const p = pos.get(a); const q = pos.get(b); const x1 = p.x + nw; const y1 = p.y + nh / 2; const x2 = q.x; const y2 = q.y + nh / 2; shape(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1), { geom: 'line', line: '#9FB8E0', lw: 2, flip: y2 < y1 }); }
|
|
513
|
+
d.nodes.forEach((n) => { const p = pos.get(n.id); const t = tn(n.tone); const [bg, ink] = t === 'bad' ? PILLC.bad : t === 'watch' || t === 'late' ? PILLC.watch : ['#FFFFFF', C.ink]; shape(p.x, p.y, nw, nh, { geom: 'roundRect', adj: round(14, nw, nh), fill: bg, line: bg === '#FFFFFF' ? '#DCDDE0' : null });
|
|
514
|
+
text(p.x + 8, p.y + 18, nw - 16, 20, n.label, o({ size: 15, bold: true, color: ink, align: 'ctr' })); });
|
|
515
|
+
} else plain();
|
|
516
|
+
if (ins) {
|
|
517
|
+
shape(902, y, 1, BOT - y, { fill: C.hair, name: 'Insight rule' });
|
|
518
|
+
const paras = [[['✦ Insight', o({ size: 13, bold: true, color: C.blue })]]];
|
|
519
|
+
if (ins.head) paras.push([[ins.head, o({ size: 18, bold: true, color: C.ink })]]);
|
|
520
|
+
if (ins.text) paras.push([[ins.text, o({ size: 15, color: C.ink2 })]]);
|
|
521
|
+
(ins.points || []).forEach((p) => paras.push([['● ', o({ size: 11, color: DOTC[tn(p.tone)] })], [p.text, o({ size: 15, color: C.ink2 })]]));
|
|
522
|
+
if (ins.note) paras.push([[ins.note, o({ size: 13, color: C.ink3 })]]);
|
|
523
|
+
text(927, y, 315, BOT - y, paras, o({ size: 15 }));
|
|
524
|
+
}
|
|
525
|
+
shape(38, 664, 1204, 1, { fill: C.hair, name: 'Footer rule' });
|
|
526
|
+
text(38, 676, 800, 20, s.source || '', o({ size: 13, color: C.ink3, name: 'Source' }));
|
|
527
|
+
if (s.deck) text(850, 676, 240, 20, s.deck, o({ size: 13, bold: true, color: C.ink2, align: 'r', name: 'Deck' }));
|
|
528
|
+
text(1100, 676, 142, 20, `${s.n || ''}${s.total ? ' / ' + s.total : ''}`, o({ size: 13, bold: true, color: C.ink, align: 'r', name: 'Page number' }));
|
|
529
|
+
}
|
|
530
|
+
const has = (v) => v != null && String(v).trim() !== '';
|
|
531
|
+
|
|
380
532
|
function pptx(report, deck) {
|
|
381
533
|
const slides = []; // { title, lines, kind }
|
|
382
534
|
slides.push({ kind: 'title', title: report.title, lines: report.subtitle ? [report.subtitle] : [] });
|
|
@@ -396,7 +548,7 @@ function pptx(report, deck) {
|
|
|
396
548
|
};
|
|
397
549
|
const look = deck ? deck.look : null;
|
|
398
550
|
const font = look ? xml(look.font) : 'Calibri';
|
|
399
|
-
if (deck) { slides.length = 0; for (const p of deck.slides) slides.push({ kind: p.kind, xml: pyramidSlideXml(p, look) }); }
|
|
551
|
+
if (deck) { slides.length = 0; for (const p of deck.slides) slides.push({ kind: p.kind, xml: pyramidSlideXml(deck.frame === 'b2' ? { ...p, deck: deck.deck || '', total: deck.slides.length } : p, look) }); }
|
|
400
552
|
const shapesSlide = (parts) => `${DECL}<p:sld xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>${parts.join('')}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
|
|
401
553
|
const layoutXml = `${DECL}<p:sldLayout xmlns:a="${NS_A}" xmlns:r="${NS_R}" xmlns:p="${NS_P}" type="obj" preserve="1"><p:cSld name="Title and Content"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>`
|
|
402
554
|
+ sp(2, 'Title 1', 'title', 609600, 365760, 10972800, 914400, '<a:p><a:endParaRPr lang="en-GB"/></a:p>')
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-library.js — the report library's sample deck: each of the 23 slide layouts (lib/report-slides.js) filled with the placeholder words of
|
|
3
|
+
* the agreed canvas (https://claude.ai/artifact/D1ssyUsrbTtxysSuibSjmV, boards L02–L23). It is what `pmos report library` writes, so the
|
|
4
|
+
* look can be checked against the boards in a browser, and what the tests draw.
|
|
5
|
+
*
|
|
6
|
+
* sampleDeck({ today }) → { name, today, slides } (pure; today = YYYY-MM-DD, default 2026-11-04, the canvas's day)
|
|
7
|
+
* libraryHtml(o) → the sample deck as one page (lib/report-slides.js deckHtml); o.template = one template's deck instead
|
|
8
|
+
* TEMPLATES · templateOf(id) → the 7 report templates (boards T02–T08): a default order of layouts, never a fixed structure
|
|
9
|
+
* templateDeck(id, o) → that template with placeholder words: its card, then each part in its layout
|
|
10
|
+
*
|
|
11
|
+
* Placeholder words only: nothing here is a reading, so it never goes into a report.
|
|
12
|
+
*/
|
|
13
|
+
const RS = require('./report-slides.js');
|
|
14
|
+
|
|
15
|
+
const INSIGHT = (head) => ({ head, text: 'One or two sentences that say what the main part means.',
|
|
16
|
+
points: [{ tone: 'ok', text: 'Good news first' }, { tone: 'watch', text: 'Something to watch' }, { tone: 'bad', text: 'Something that needs a decision' }],
|
|
17
|
+
note: 'Source line or caveat for the insight.' });
|
|
18
|
+
|
|
19
|
+
function sampleDeck({ today = '2026-11-04' } = {}) {
|
|
20
|
+
const S = (layout, eyebrow, title, sub, data, insight) => ({ layout, eyebrow: `${eyebrow} · Slide layout`, title, sub, data, insight: insight ? INSIGHT(insight) : null, source: 'Layout library · placeholder data' });
|
|
21
|
+
const slides = [
|
|
22
|
+
S('overview', 'Overview', 'Pick the layout that fits the message, then fill it', 'Every layout sits in the same frame and uses PMOS components. Change the words, keep the shape.',
|
|
23
|
+
{ lead: 'One frame, 23 bodies. Each layout answers one kind of question.', cols: [{ head: 'Say it', text: 'Overview, statement, focus, agenda' }, { head: 'Show it', text: 'Tiles, ledger, donut, heatmap, bubbles, timeline' }, { head: 'Decide it', text: 'Compare, lists, risks, ranked, decisions, actions' }] }),
|
|
24
|
+
S('tiles', 'Tiles', 'Three numbers that matter, each against its target', 'Use for a scorecard of up to four measures.',
|
|
25
|
+
{ tiles: [{ label: 'Measure one', value: '82%', note: 'Short note on how it moved', share: 0.82, target: 1, tone: 'ok' }, { label: 'Measure two', value: '4.1%', note: 'Short note on how it moved', share: 0.6, target: 1, tone: 'info' }, { label: 'Measure three', value: '240', note: 'Short note on how it moved', share: 0.9, target: 0.7, tone: 'bad' }] }, 'What the tiles say'),
|
|
26
|
+
S('ledger', 'Ledger', 'Goals, nearest the line first', 'Use for four to six goals with value, progress bar, target mark and verdict.',
|
|
27
|
+
{ label: 'Goals', rows: [{ name: 'Goal one', value: '93%', share: 0.93, target: 0.95, target_text: 'Target 95%', verdict: 'On the line', tone: 'ok' }, { name: 'Goal two', value: '4.1%', share: 0.68, target: 0.8, target_text: 'Target under 3%', verdict: 'Closing', tone: 'ok' }, { name: 'Goal three', value: '+3.1%', share: 0.78, target: 1, target_text: 'Target +4%', verdict: 'One week short', tone: 'watch' }, { name: 'Goal four', value: '240', share: 0.9, target: 0.7, target_text: 'Goal under 200', verdict: 'Above', tone: 'bad' }] }, 'Which goal needs attention'),
|
|
28
|
+
S('timeline', 'Timeline', 'Dates on one line, with the risky ones marked', 'Interactive: switch the span, filter to at-risk. Use for a plan or a portfolio.',
|
|
29
|
+
{ today, rows: [{ name: 'Item one', start: '2026-11-02', end: '2026-12-04', tag: 'Design', tone: 'none' }, { name: 'Item two', start: '2026-11-20', end: '2027-01-22', tag: 'Build', tone: 'ok' }, { name: 'Item three', start: '2026-12-07', end: '2027-02-05', tag: 'Build', tone: 'watch' }, { name: 'Item four', start: '2027-01-25', end: '2027-02-12', tag: 'Pilot', tone: 'none' }] }, 'What the dates say'),
|
|
30
|
+
S('compare', 'Compare', 'What went well, next to what did not', 'Two columns of tiles. Use for before and after, shipped and slipped, now and needed.',
|
|
31
|
+
{ left: { label: 'Shipped', items: [{ value: '–32%', name: 'metric one', note: 'Short explanation', share: 0.68, target: 0.8, tone: 'ok' }, { value: '11', name: 'metric two', note: 'Short explanation', share: 0.8, target: 1, tone: 'ok' }] },
|
|
32
|
+
right: { label: 'Slipped', items: [{ value: '+17%', name: 'metric three', note: 'Short explanation', share: 0.9, target: 0.7, tone: 'bad' }, { value: '3 weeks', name: 'metric four', note: 'Short explanation', share: 0.45, target: 1, tone: 'watch' }] } }, 'Why the split matters'),
|
|
33
|
+
S('lists', 'Two lists', 'What is in, what is out', 'Two columns of short items. Use for scope, open and parked, reached and not reached.',
|
|
34
|
+
{ left: { label: 'Left list', tone: 'ok', items: [{ text: 'Item one', detail: 'Detail' }, { text: 'Item two' }, { text: 'Item three' }] }, right: { label: 'Right list', tone: 'bad', items: [{ text: 'Item four', detail: 'Detail' }, { text: 'Item five' }, { text: 'Item six' }] } }, 'The rule between the lists'),
|
|
35
|
+
S('risks', 'Risk table', 'Risks with owner, first move and level', 'Use for four to five risks. Level sets the pill colour.',
|
|
36
|
+
{ rows: [{ name: 'Risk one', owner: 'Owner', why: 'why it matters', move: 'First move', level: 'Possible', tone: 'watch' }, { name: 'Risk two', owner: 'Owner', why: 'why it matters', move: 'First move', level: 'Likely', tone: 'bad' }, { name: 'Risk three', owner: 'Owner', why: 'why it matters', move: 'First move', level: 'Low', tone: 'ok' }] }, 'Which risk is real'),
|
|
37
|
+
S('ranked', 'Ranked list', 'Options ranked by impact and effort', 'Two numeric columns with a bar. Use for bets and prioritisation.',
|
|
38
|
+
{ a: 'Impact', b: 'Effort, weeks', rows: [{ name: 'Option one', a: 0.8, b: 0.3 }, { name: 'Option two', a: 0.6, b: 0.2 }, { name: 'Option three', a: 0.4, b: 0.6 }] }, 'Why the order'),
|
|
39
|
+
S('decisions', 'Decisions', 'Three decisions we want from the room', 'Each row holds a ✦ proposal, an owner and a date. Use at the end of any deck.',
|
|
40
|
+
{ rows: [{ question: 'Decision one as a question?', pick: 'My pick and one line of reason.', who: 'Owner', by: today }, { question: 'Decision two as a question?', pick: 'Cost and consequence in one line.', who: 'Owner', by: '2026-11-06' }, { question: 'Decision three as a question?', pick: 'Cost and consequence in one line.', who: 'Owner', by: '2026-11-20' }] }, 'How the decisions connect'),
|
|
41
|
+
S('quotes', 'Quotes', 'What people said, with who and when', 'Three quotes plus an agreed line. Use for meeting recaps and customer voice.',
|
|
42
|
+
{ quotes: [{ text: "Quote that explains the decision in the speaker's own words.", who: 'Name', at: '00:04:12' }, { text: "Quote that explains the decision in the speaker's own words.", who: 'Name', at: '00:11:40' }, { text: "Quote that explains the decision in the speaker's own words.", who: 'Name', at: '00:18:05' }], agreed: 'one line that sums up what the room decided.' }, 'What the quotes add'),
|
|
43
|
+
S('actions', 'Actions', 'Action items that can be ticked off', 'Interactive: tick to close. Use for recaps and weekly notes.',
|
|
44
|
+
{ rows: [{ text: 'Action one', who: 'Owner', due: '2026-11-05' }, { text: 'Action two', who: 'Owner', due: '2026-11-06' }, { text: 'Action three', who: 'Owner', due: '2026-11-10' }, { text: 'Action four', who: 'Owner', due: '2026-11-13' }] }, 'Which are due first'),
|
|
45
|
+
S('agenda', 'Agenda', 'Three things in order', 'Numbered rows with a title and one line. Use for plans, agendas and next steps.',
|
|
46
|
+
{ rows: [{ title: 'First thing', text: 'One line of detail' }, { title: 'Second thing', text: 'One line of detail' }, { title: 'Third thing', text: 'One line of detail' }] }, 'Why this order'),
|
|
47
|
+
S('heatmap', 'Heatmap', 'Programs by product, hot cells in red', 'Grid with a side total. Use for portfolio health.',
|
|
48
|
+
{ cols: ['Col 1', 'Col 2', 'Col 3', 'Col 4'], total: 'Spend', rows: [
|
|
49
|
+
{ name: 'Row A', total: '$180k', cells: [{ text: 'Healthy', tone: 'ok' }, { text: 'Healthy', tone: 'ok' }, { text: 'At risk', tone: 'watch' }, { text: 'Healthy', tone: 'ok' }] },
|
|
50
|
+
{ name: 'Row B', total: '$420k', cells: [{ text: 'Healthy', tone: 'ok' }, { text: 'Late', tone: 'bad' }, { text: 'At risk', tone: 'watch' }, { text: 'Healthy', tone: 'ok' }] },
|
|
51
|
+
{ name: 'Row C', total: '$90k', cells: [{ text: 'At risk', tone: 'watch' }, { text: 'At risk', tone: 'watch' }] }] }, 'Where the heat is'),
|
|
52
|
+
S('network', 'Network', 'What waits on what', 'Nodes and arrows. Use for dependencies and flows.',
|
|
53
|
+
{ nodes: [{ id: 'a', label: 'Node A', tone: 'bad' }, { id: 'b', label: 'Node B' }, { id: 'c', label: 'Node C', tone: 'watch' }, { id: 'd', label: 'Node D' }], edges: [['a', 'b'], ['a', 'c'], ['b', 'd'], ['c', 'd']] }, 'Where to unblock'),
|
|
54
|
+
S('schedule', 'Schedule', 'Who did what on which day', 'Week grid with bars. Use for weekly reviews and rosters.',
|
|
55
|
+
{ cols: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], rows: [{ name: 'Work one', from: 0, to: 1, text: 'Done', tone: 'ok' }, { name: 'Work two', from: 1, to: 3, text: 'In progress', tone: 'info' }, { name: 'Work three', from: 2, to: 4, text: 'Blocked', tone: 'watch' }, { name: 'Work four', from: 0, to: 4, text: 'Waiting', tone: 'none' }] }, 'How the week went'),
|
|
56
|
+
S('donut', 'Donut', 'Share of the whole, with a headline', 'Ring with a centre number and a legend. Use for health and mix.',
|
|
57
|
+
{ center: '7 of 11', center_note: 'healthy', parts: [{ label: 'Healthy', n: 7, names: 'Names', tone: 'ok' }, { label: 'At risk', n: 2, names: 'Names', tone: 'watch' }, { label: 'Late', n: 2, names: 'Names', tone: 'bad' }] }, 'The red part'),
|
|
58
|
+
S('bubbles', 'Bubbles', 'Spend against outcome', 'Scatter with size. Use to spot what costs a lot and returns little.',
|
|
59
|
+
{ x: 'More spend', y: 'Outcome', points: [{ label: 'A', x: 0.7, y: 0.62, size: 0.53, tone: 'info' }, { label: 'B', x: 0.4, y: 0.84, size: 0.27, tone: 'ok' }, { label: 'C', x: 0.6, y: 0.4, size: 0.33, tone: 'watch' }, { label: 'D', x: 0.85, y: 0.14, size: 0.87, tone: 'bad' }] }, 'The outlier'),
|
|
60
|
+
S('people', 'People', 'Who owns what', 'Avatar rows with role and remit. Use for kick-offs and handovers.',
|
|
61
|
+
{ people: [{ name: 'Name One', initials: 'AB', role: 'Role', owns: 'What they own' }, { name: 'Name Two', initials: 'CD', role: 'Role', owns: 'What they own' }, { name: 'Name Three', initials: 'EF', role: 'Role', owns: 'What they own' }] }, 'Who decides'),
|
|
62
|
+
S('statement', 'Statement', 'One sentence that carries the slide', 'Big sentence and three supporting numbers. Use for the why.',
|
|
63
|
+
{ text: 'One sentence of 25 words or fewer that carries the argument of this slide.', figures: [{ value: '–1.4 s', label: 'what it measures', source: 'source', tone: 'ok' }, { value: '3×', label: 'what it measures', source: 'source', tone: 'info' }, { value: '11 days', label: 'what it measures', source: 'source', tone: 'watch' }] }, 'The case in one number'),
|
|
64
|
+
S('sources', 'Sources', 'What was read, and what could not be', 'Reached on the left, not reached on the right. Close every report with it.',
|
|
65
|
+
{ read: [{ name: 'Source one', at: '2026-11-02' }, { name: 'Source two', at: today }], missing: [{ name: 'Missing source', why: 'Not connected' }] }, 'What the gap costs'),
|
|
66
|
+
S('minutes', 'Minutes', 'Where the time went', 'Ranked bars with a unit. Use for meeting topics and effort.',
|
|
67
|
+
{ unit: 'min', rows: [{ name: 'Topic one', note: 'Short note', value: 14 }, { name: 'Topic two', note: 'Short note', value: 9 }, { name: 'Topic three', note: 'Short note', value: 6 }] }, 'Time and risk'),
|
|
68
|
+
S('focus', 'Focus', 'A four-step path with a gate', 'Callout plus four steps. Use for the first thirty days.',
|
|
69
|
+
{ gate: { title: 'Gate · date', text: 'What must be true at the gate.' }, steps: [{ label: 'Week 1', title: 'Step title', text: 'What happens.' }, { label: 'Week 2', title: 'Step title', text: 'What happens.' }, { label: 'Week 3', title: 'Step title', text: 'What happens.' }, { label: 'Week 4', title: 'Step title', text: 'What happens.' }] }, 'Why the gate is real'),
|
|
70
|
+
S('next', 'Next', 'Next meeting, date and agenda', 'Date block plus timed items.',
|
|
71
|
+
{ date: '2026-11-10', time: '10:00', items: [{ length: '5 min', text: 'Item' }, { length: '15 min', text: 'Item' }, { length: '10 min', text: 'Item' }] }, 'How the agenda is built'),
|
|
72
|
+
];
|
|
73
|
+
slides.forEach((s, i) => { s.n = i + 1; });
|
|
74
|
+
// the footer names the layout and the templates that use it (boards L01–L23)
|
|
75
|
+
for (const sl of slides) {
|
|
76
|
+
const uses = TEMPLATES.filter((t) => t.outline.some((o) => o.layout === sl.layout)).map((t) => t.name);
|
|
77
|
+
sl.source = ['Layout: ' + (NAMES[sl.layout] || sl.layout), uses.length ? uses.join(', ') : 'no template yet', 'placeholder data'].join(' · ');
|
|
78
|
+
}
|
|
79
|
+
return { name: 'Slide layouts', today, slides };
|
|
80
|
+
}
|
|
81
|
+
/* THE TEMPLATES (canvas boards T02–T08). A template is a DEFAULT ORDER of layouts, never a fixed structure: the writer keeps what is true and
|
|
82
|
+
* adds or drops a slide when the findings say so (Wawan, 2026-11-03: no fixed structure). Each part: { part, layout }. A recurring report keeps
|
|
83
|
+
* the outline its readers agreed; a template only starts a report that has none. */
|
|
84
|
+
const T = (id, name, title, sub, use, readers, cadence, parts) => Object.freeze({ id, name, title, sub, use, readers, cadence,
|
|
85
|
+
outline: Object.freeze(parts.map(([part, layout]) => Object.freeze({ part, layout }))) });
|
|
86
|
+
const TEMPLATES = Object.freeze([
|
|
87
|
+
T('weekly-review', 'Weekly Review', 'Weekly Review: what moved, what slipped, what waits on you', 'Eight slides. Read in five minutes.', 'Every Monday for a team lead or sponsor.', 'team lead, sponsor', 'weekly',
|
|
88
|
+
[['Title with agenda', 'overview'], ['This week', 'tiles'], ['Key results', 'ledger'], ['What changed', 'compare'], ['The week', 'schedule'], ['Asks', 'decisions'], ['Next week', 'agenda'], ['Sources', 'sources']]),
|
|
89
|
+
T('program-kickoff', 'Program Kick-off', 'Program Kick-off: why, who, when, risks, first thirty days', 'Ten slides. Presented once, then kept as the charter.', 'At the start of a program or initiative.', 'sponsor, leads, partners', 'once per program',
|
|
90
|
+
[['Title', 'overview'], ['Why', 'statement'], ['Scope', 'lists'], ['People', 'people'], ['Milestones', 'timeline'], ['Risks', 'risks'], ['Ways of working', 'agenda'], ['Success', 'tiles'], ['First 30 days', 'focus'], ['Decisions', 'decisions']]),
|
|
91
|
+
T('meeting-recap', 'Meeting Recap', 'Meeting Recap: decisions, actions, open questions', 'Seven slides. The two-minute read for people who were not there.', 'Within an hour of a meeting.', 'attendees and absentees', 'after each meeting',
|
|
92
|
+
[['Title', 'overview'], ['The short version', 'agenda'], ['Decisions', 'quotes'], ['Action items', 'actions'], ['Discussion', 'minutes'], ['Open and parked', 'lists'], ['Next', 'next']]),
|
|
93
|
+
T('portfolio-snapshot', 'Portfolio Snapshot', 'Portfolio Snapshot: health, money, dependencies, decisions', 'Nine slides. One sitting.', 'For leadership who own several programs.', 'leadership', 'every two weeks',
|
|
94
|
+
[['Title', 'overview'], ['Health', 'donut'], ['Heatmap', 'heatmap'], ['Timeline', 'timeline'], ['Money and outcome', 'bubbles'], ['Top risks', 'risks'], ['Dependencies', 'network'], ['Spotlight', 'compare'], ['Decisions', 'decisions']]),
|
|
95
|
+
T('quarterly-review', 'Quarterly Review', 'Quarterly Review: answer first, then goals, bets, decisions', 'Nine slides. Leads with the answer, ends with sources.', 'At the end of each quarter.', 'leadership, board', 'quarterly',
|
|
96
|
+
[['Title', 'overview'], ['Answer', 'statement'], ['Scorecard', 'tiles'], ['Timeline', 'timeline'], ['Wins and misses', 'lists'], ['Risks', 'risks'], ['Bets', 'ranked'], ['Decisions', 'decisions'], ['Sources', 'sources']]),
|
|
97
|
+
T('decision-brief', 'Decision Brief', 'Decision Brief: the question, the options, the pick', 'Six slides. For a single call that needs a yes.', 'When one person must decide and needs the facts.', 'the decider', 'ad hoc',
|
|
98
|
+
[['The question', 'statement'], ['Options', 'compare'], ['Ranked by impact', 'ranked'], ['Risks of the pick', 'risks'], ['The ask', 'decisions'], ['Sources', 'sources']]),
|
|
99
|
+
T('incident-review', 'Incident Review', 'Incident Review: what happened, why, what we change', 'Six slides. Blameless and short.', 'Within a week of an incident.', 'engineering, support, leadership', 'after incident',
|
|
100
|
+
[['What happened', 'statement'], ['Timeline of the day', 'timeline'], ['Causes ranked', 'ranked'], ['What went well and not', 'lists'], ['Actions', 'actions'], ['Sources', 'sources']]),
|
|
101
|
+
]);
|
|
102
|
+
const templateOf = (id) => TEMPLATES.find((t) => t.id === id) || null;
|
|
103
|
+
const NAMES = { overview: 'Overview', tiles: 'Tiles', ledger: 'Ledger', timeline: 'Timeline', compare: 'Compare', lists: 'Two lists', risks: 'Risk table', ranked: 'Ranked list',
|
|
104
|
+
decisions: 'Decisions', quotes: 'Quotes', actions: 'Actions', agenda: 'Agenda', heatmap: 'Heatmap', network: 'Network', schedule: 'Schedule', donut: 'Donut', bubbles: 'Bubbles',
|
|
105
|
+
people: 'People', statement: 'Statement', sources: 'Sources', minutes: 'Minutes', focus: 'Focus', next: 'Next' };
|
|
106
|
+
/* One row per part (the agenda numbers them); past eight parts, two parts share a row ("Title · Why"), as the canvas cards pair them. */
|
|
107
|
+
function cardRows(outline) {
|
|
108
|
+
const one = outline.map((o) => ({ title: o.part, text: NAMES[o.layout] || o.layout }));
|
|
109
|
+
if (one.length <= 8) return one;
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (let i = 0; i < one.length; i += 2) rows.push(one[i + 1] ? { title: `${one[i].title} · ${one[i + 1].title}`, text: `${one[i].text} · ${one[i + 1].text}` } : one[i]);
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
/** The template's card (boards T02–T08): its default order as numbered rows, and when to use it in the insight column. */
|
|
115
|
+
function templateCard(t) {
|
|
116
|
+
return { layout: 'agenda', eyebrow: `${t.name} · Report template`, title: t.title, sub: t.sub,
|
|
117
|
+
data: { rows: cardRows(t.outline) },
|
|
118
|
+
insight: { head: 'When to use it', text: t.use, points: [{ tone: 'ok', text: `Readers: ${t.readers}` }, { tone: 'ok', text: `Cadence: ${t.cadence}` }, { tone: 'watch', text: 'Add or drop slides when the findings say so' }],
|
|
119
|
+
note: 'Order is the default; the writer may change it per report.' },
|
|
120
|
+
source: 'Template library · sample data' };
|
|
121
|
+
}
|
|
122
|
+
/** A template drawn with placeholder words: its card, then each part in its layout (the sample of that layout, titled by the part). */
|
|
123
|
+
function templateDeck(id, o = {}) {
|
|
124
|
+
const t = templateOf(id);
|
|
125
|
+
if (!t) return null;
|
|
126
|
+
const lib = sampleDeck(o).slides;
|
|
127
|
+
const slides = [templateCard(t)].concat(t.outline.map((p) => { const s = lib.find((x) => x.layout === p.layout); return { ...s, eyebrow: `${t.name} · ${p.part}`, title: p.part, sub: s.title, source: 'Template library · placeholder data' }; }));
|
|
128
|
+
slides.forEach((s, i) => { s.n = i + 1; });
|
|
129
|
+
return { name: t.name, today: o.today || '2026-11-04', slides };
|
|
130
|
+
}
|
|
131
|
+
/** The library page: every layout, or one template (o.template). */
|
|
132
|
+
const libraryHtml = (o = {}) => RS.deckHtml(o.template ? templateDeck(o.template, o) : sampleDeck(o));
|
|
133
|
+
|
|
134
|
+
module.exports = { sampleDeck, libraryHtml, TEMPLATES, templateOf, templateCard, templateDeck };
|
|
@@ -490,6 +490,8 @@ function sectionBlockHtml(b, L) {
|
|
|
490
490
|
/** The deck as one HTML string (the viewer's frames and the PDF print). */
|
|
491
491
|
function deckHtml(deck) {
|
|
492
492
|
if (!deck) return '';
|
|
493
|
+
// the report engine's deck is drawn in the report library's frame (B2, lib/report-slides.js; batch 27 item 14)
|
|
494
|
+
if (deck.frame === 'b2') return require('./report-slides.js').deckHtml(deck);
|
|
493
495
|
const L = { ...STANDARD, ...(deck.look || {}) };
|
|
494
496
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(deck.name)}</title><style>${css(L)}</style></head><body>${deck.slides.map((s) => slideHtml(s, L)).join('')}</body></html>`;
|
|
495
497
|
}
|