create-pmos 0.530.0 → 0.531.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 +14 -28
- 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 +13 -0
- package/workspace/guide/reports.md +3 -1
- package/workspace/lib/report-cli.js +16 -3
- package/workspace/lib/report-engine.js +67 -17
- package/workspace/lib/report-export.js +155 -3
- package/workspace/lib/report-library.js +76 -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 +2 -1
- package/workspace/test-lists/report-slides.json +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.531.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.531.0",
|
|
37
|
+
"at": "2026-11-04T18:44:50.726Z",
|
|
38
|
+
"f": 4,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1247,
|
|
42
|
+
"t": "Report library frame and 23 slide layouts",
|
|
43
|
+
"s": "Report library frame and 23 slide layouts",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
35
48
|
{
|
|
36
49
|
"v": "0.530.0",
|
|
37
50
|
"at": "2026-11-04T14:38:26.528Z",
|
|
@@ -406,33 +419,6 @@
|
|
|
406
419
|
"k": "new"
|
|
407
420
|
}
|
|
408
421
|
]
|
|
409
|
-
},
|
|
410
|
-
{
|
|
411
|
-
"v": "0.511.0",
|
|
412
|
-
"at": "2026-11-03T15:10:29.930Z",
|
|
413
|
-
"f": 4,
|
|
414
|
-
"prs": [
|
|
415
|
-
{
|
|
416
|
-
"n": 1202,
|
|
417
|
-
"t": "A closed initiative is never late on the Board",
|
|
418
|
-
"s": "A closed initiative is never late on the Board",
|
|
419
|
-
"k": "fix"
|
|
420
|
-
},
|
|
421
|
-
{
|
|
422
|
-
"n": 1200,
|
|
423
|
-
"t": "Open the chat on the Board, and dock panels left or right",
|
|
424
|
-
"s": "Open the chat on the Board, and dock panels left or right",
|
|
425
|
-
"k": "new"
|
|
426
|
-
},
|
|
427
|
-
{
|
|
428
|
-
"n": 1201,
|
|
429
|
-
"t": "Settings Updates tells each change as Claude's plain-language story (design E)",
|
|
430
|
-
"s": "Settings Updates tells each change as Claude's plain-language story (design E)",
|
|
431
|
-
"k": "new",
|
|
432
|
-
"h": "Updates read like a story",
|
|
433
|
-
"d": "Updates listed pull request titles and numbers, grouped by kind, which was hard to skim. Updates opens with a short summary of the week, then the newest version, then each change as a plain story, with a dot for New and Fixed."
|
|
434
|
-
}
|
|
435
|
-
]
|
|
436
422
|
}
|
|
437
423
|
]
|
|
438
424
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.531.0",
|
|
4
|
+
"at": "2026-11-04T18:44:50.726Z",
|
|
5
|
+
"f": 4,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1247,
|
|
9
|
+
"t": "Report library frame and 23 slide layouts",
|
|
10
|
+
"s": "Report library frame and 23 slide layouts",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"v": "0.530.0",
|
|
4
17
|
"at": "2026-11-04T14:38:26.528Z",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Reports: draft and issue a cadence report"
|
|
4
4
|
description: "How pmos report drafts a cadence report with every number, how the notes are kept when you draft again, and how you issue and freeze a report."
|
|
5
|
-
timestamp: 2026-11-
|
|
5
|
+
timestamp: 2026-11-04
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Reports: draft and issue a cadence report
|
|
@@ -90,6 +90,8 @@ pmos report issue biweekly 2026-10-16
|
|
|
90
90
|
|
|
91
91
|
`pmos report finder` shows the report finder setting, or changes it.
|
|
92
92
|
|
|
93
|
+
`pmos report library` writes one page with every slide layout of the report library, filled with placeholder words. Open it in a browser to see the layouts a report can use. Add `--out <file>` to choose the file.
|
|
94
|
+
|
|
93
95
|
Every command accepts `--root <dir>` to name the repository. `draft` and `list` also accept `--json`.
|
|
94
96
|
|
|
95
97
|
## The window
|
|
@@ -37,7 +37,7 @@ const actorLib = require('./actor.js');
|
|
|
37
37
|
const RS = require('./report-setups.js');
|
|
38
38
|
const { parseArgs } = require('./schedule-cli');
|
|
39
39
|
|
|
40
|
-
const COMMANDS = { report: ['make', 'read', 'find', 'write', 'check', 'rule', 'draft', 'issue', 'list', 'propose', 'definitions', 'setups', 'finder'] };
|
|
40
|
+
const COMMANDS = { report: ['make', 'read', 'find', 'write', 'check', 'rule', 'draft', 'issue', 'list', 'propose', 'definitions', 'setups', 'finder', 'library'] };
|
|
41
41
|
const USAGE = [
|
|
42
42
|
'usage: pmos report make|read|find|write|check|rule — the report engine: brief, read, find, answer, render (any report, any shape)',
|
|
43
43
|
' pmos report make <id> [--ask "<the question>"] [--readers "<who>"] [--source <kind:ref>,…] [--slot <date>]',
|
|
@@ -54,6 +54,8 @@ const USAGE = [
|
|
|
54
54
|
' pmos report check <id> [--slot <date>] the checks of the last write: substance first, then form',
|
|
55
55
|
' pmos report rule <id> list | add <voice|wording|off|keep> "<text or part>" | remove <n>',
|
|
56
56
|
' What the readers taught this report: a voice or wording rule, or a part beyond the ask turned off or kept.',
|
|
57
|
+
'usage: pmos report library [--out <file>] — the slide library: every layout in the report frame, with placeholder words',
|
|
58
|
+
' Writes one HTML page (default: report-library.html in the current folder). Open it in a browser to check the look.',
|
|
57
59
|
'usage: pmos report draft|issue|list — cadence reports (for example the biweekly)',
|
|
58
60
|
' pmos report draft <cadence> [<slot>] [--now <time>] [--redraft]',
|
|
59
61
|
' Write the draft for a slot (default: today) with every number. Refuses a slot that has a draft;',
|
|
@@ -74,9 +76,9 @@ const USAGE = [
|
|
|
74
76
|
' every command: [--json] [--root <dir>]. The cadence is the report skill id without "report-", for example biweekly.',
|
|
75
77
|
];
|
|
76
78
|
const BOOL = new Set(['json', 'redraft', 'help']);
|
|
77
|
-
const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from']);
|
|
79
|
+
const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from', 'out']);
|
|
78
80
|
const ALLOWED = { make: ['json', 'now', 'root', 'ask', 'readers', 'source', 'slot'], read: ['json', 'now', 'root', 'slot', 'from', 'source'], find: ['json', 'now', 'root', 'slot', 'from'],
|
|
79
|
-
write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'] };
|
|
81
|
+
write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'], library: ['json', 'out'] };
|
|
80
82
|
const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
81
83
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
82
84
|
|
|
@@ -145,6 +147,17 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
145
147
|
return 0;
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
if (verb === 'library') {
|
|
151
|
+
// the slide library as one page: sanitized as a report render is, with the workspace face inlined so the file shows it anywhere
|
|
152
|
+
const out = path.resolve(cwd || process.cwd(), typeof flags.out === 'string' && flags.out ? flags.out : 'report-library.html');
|
|
153
|
+
const D = require('./report-design.js');
|
|
154
|
+
let face = '';
|
|
155
|
+
try { face = `<style>@font-face{font-family:"Plus Jakarta Sans";font-weight:200 800;src:url(data:font/woff2;base64,${fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'plus-jakarta-sans-var.woff2')).toString('base64')}) format("woff2")}body{margin:0;background:#888}.pmos-slide{margin:0 auto 12px}</style>`; } catch { face = ''; }
|
|
156
|
+
const html = D.sanitize(require('./report-library.js').libraryHtml());
|
|
157
|
+
try { fs.writeFileSync(out, html.replace('<style>', face + '<style>')); } catch (e) { return fail(`could not write ${out}: ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
158
|
+
if (json) say(JSON.stringify({ ok: true, path: out })); else say(`Wrote ${out}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
148
161
|
if (['make', 'read', 'find', 'write', 'check', 'rule'].includes(verb)) return engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin, now });
|
|
149
162
|
if (verb === 'definitions' || verb === 'setups' || verb === 'propose' || verb === 'finder') return reportWorkspace(verb, { pos, flags, root, json, say, fail, cwd, isTTY, env, stdin, now });
|
|
150
163
|
|
|
@@ -456,6 +456,10 @@ function textsOf(report) {
|
|
|
456
456
|
if (isObj(report.answer)) { add('answer', report.answer.text); (report.answer.supports || []).forEach((s, i) => add(`answer.supports[${i}]`, s)); }
|
|
457
457
|
(report.sections || []).forEach((s, i) => {
|
|
458
458
|
add(`sections[${i}].title`, s.title);
|
|
459
|
+
add(`sections[${i}].eyebrow`, s.eyebrow); add(`sections[${i}].sub`, s.sub);
|
|
460
|
+
// a section bound to a library layout (lib/report-slides.js): every text in its data and its insight is the writer's, so it is checked too
|
|
461
|
+
if (s.layout) { try { require('./report-slides.js').textsOfLayout(s.layout, s.data).forEach((t) => add(`sections[${i}].data`, t)); } catch { /* shapeError names it */ } }
|
|
462
|
+
textsOfInsight(s.insight).forEach((t) => add(`sections[${i}].insight`, t));
|
|
459
463
|
(s.blocks || []).forEach((b, j) => {
|
|
460
464
|
const w = `sections[${i}].blocks[${j}]`;
|
|
461
465
|
add(w, b.text); (b.items || []).forEach((x) => add(w, x));
|
|
@@ -475,11 +479,20 @@ function shapeError(report) {
|
|
|
475
479
|
if (report.answer.supports != null && (!Array.isArray(report.answer.supports) || report.answer.supports.length > 5 || report.answer.supports.some((s) => !isStr(s)))) return 'answer.supports is a list of at most 5 sentences';
|
|
476
480
|
if (!Array.isArray(report.sections) || report.sections.length > LIMITS.sections) return `sections is a list of at most ${LIMITS.sections} (any titles, any order: the writer chooses)`;
|
|
477
481
|
const seen = new Set();
|
|
478
|
-
for (
|
|
482
|
+
for (let [i, s] of report.sections.entries()) {
|
|
479
483
|
if (!isObj(s) || !isStr(s.title) || !s.title.trim() || /[\r\n]/.test(s.title)) return `sections[${i}] has a one-line title`;
|
|
480
484
|
const id = str(s.id) || slugOf(s.title);
|
|
481
485
|
if (seen.has(id)) return `two sections share the id ${id}`;
|
|
482
486
|
seen.add(id);
|
|
487
|
+
if (s.layout != null) {
|
|
488
|
+
const RS = require('./report-slides.js');
|
|
489
|
+
if (!RS.LAYOUTS.includes(s.layout) || s.layout === 'blocks') return `sections[${i}].layout is one of ${RS.LAYOUTS.filter((x) => x !== 'blocks').join(', ')} (or leave it out and write blocks)`;
|
|
490
|
+
const e = RS.layoutError(s.layout, s.data);
|
|
491
|
+
if (e) return `sections[${i}] (${s.layout}): ${e}`;
|
|
492
|
+
if (s.blocks == null) s = { ...s, blocks: [] };
|
|
493
|
+
}
|
|
494
|
+
if (s.insight != null && (!isObj(s.insight) || (s.insight.points != null && (!Array.isArray(s.insight.points) || s.insight.points.length > 4 || s.insight.points.some((x) => !isObj(x) || !isStr(x.text)))))) return `sections[${i}].insight is { head, text, points: [{ tone, text }] (at most 4), note }`;
|
|
495
|
+
for (const k of ['eyebrow', 'sub']) if (s[k] != null && (!isStr(s[k]) || /[\r\n]/.test(s[k]) || s[k].length > LIMITS.title)) return `sections[${i}].${k} is one line`;
|
|
483
496
|
if (!Array.isArray(s.blocks) || s.blocks.length > LIMITS.blocks) return `sections[${i}].blocks is a list of at most ${LIMITS.blocks}`;
|
|
484
497
|
for (const [j, b] of s.blocks.entries()) {
|
|
485
498
|
if (!isObj(b) || !BLOCK_KINDS.includes(b.kind)) return `sections[${i}].blocks[${j}].kind is one of ${BLOCK_KINDS.join(', ')}`;
|
|
@@ -613,7 +626,17 @@ function renderMarkdown(report, { brief = null, readings = null, now = Date.now(
|
|
|
613
626
|
}
|
|
614
627
|
for (const s of report.sections) {
|
|
615
628
|
lines.push(`## ${r(s.title)}`, '');
|
|
616
|
-
|
|
629
|
+
if (s.layout && !(s.blocks || []).length) {
|
|
630
|
+
// a section bound to a library layout and no blocks: the document holds the layout's lines and its insight, sourced like a block
|
|
631
|
+
const RS = require('./report-slides.js');
|
|
632
|
+
const texts = RS.textsOfLayout(s.layout, s.data).concat(textsOfInsight(s.insight));
|
|
633
|
+
const ls = RS.linesOf(s.layout, RS.mapLayout(s.layout, s.data, r));
|
|
634
|
+
if (ls.length) lines.push(ls.map((l) => `- ${l}`).join('\n'), '');
|
|
635
|
+
if (isObj(s.insight) && (s.insight.head || s.insight.text)) lines.push(`**${r(str(s.insight.head)) || 'Insight'}.** ${r(str(s.insight.text))}`.trim(), '');
|
|
636
|
+
const src = sourceLine(texts.flatMap(tokensIn), byId, brief);
|
|
637
|
+
if (src) lines.push(`*${src}*`, '');
|
|
638
|
+
}
|
|
639
|
+
for (const b of s.blocks || []) lines.push(blockMd(b, null, byId, brief), '');
|
|
617
640
|
}
|
|
618
641
|
for (const p of beyondParts(report)) {
|
|
619
642
|
lines.push(`## ${p.label}`, '');
|
|
@@ -644,50 +667,77 @@ function periodOf(brief, R) {
|
|
|
644
667
|
const ats = R.map((x) => x.at).sort();
|
|
645
668
|
return 'Data of ' + span(ats[0], ats[ats.length - 1]);
|
|
646
669
|
}
|
|
647
|
-
/** The deck of an engine report: the same model, drawn as slides
|
|
670
|
+
/** The deck of an engine report: the same model, drawn as slides in the report library's frame (B2, lib/report-slides.js; batch 27 item 14).
|
|
671
|
+
* Each slide keeps its kind (title, answer, section, beyond, ask, sources) and names its layout: the title is the overview with the sections as its
|
|
672
|
+
* agenda, the answer lists its supports with the ask in the insight column, a section is drawn by the layout it names (else its blocks), the parts
|
|
673
|
+
* beyond the question are a numbered list, the ask is the decisions layout and the sources close the deck. lib/report-pyramid.js deckHtml and
|
|
674
|
+
* lib/report-export.js pptx draw it. */
|
|
648
675
|
function deckOf(report, { brief = null, readings = null, look = null } = {}) {
|
|
676
|
+
const RS = require('./report-slides.js');
|
|
649
677
|
const R = Array.isArray(readings && readings.readings) ? readings.readings : [];
|
|
650
678
|
const byId = new Map(R.map((r) => [r.id, r]));
|
|
651
679
|
const r = (t) => String(resolve(t, byId));
|
|
652
680
|
const srcOf = (ids) => sourceLine(ids, byId, brief).replace(/\.$/, '');
|
|
653
681
|
const slides = [];
|
|
682
|
+
const deckName = (brief && brief.title) || '';
|
|
654
683
|
const titleText = clip(r(report.title), 160);
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
684
|
+
const firstLine = (s) => { const ins = isObj(s.insight) ? str(s.insight.head) || str(s.insight.text) : ''; const p = (s.blocks || []).find((b) => b && b.kind === 'p'); return ins || str(s.sub) || (p ? str(p.text) : ''); };
|
|
685
|
+
const agenda = report.sections.slice(0, 4).map((s) => ({ head: clip(r(s.title), 80), text: clip(r(firstLine(s)), 110) }));
|
|
686
|
+
slides.push({ kind: 'title', layout: agenda.length ? 'overview' : 'statement', eyebrow: [brief && brief.title, brief && brief.readers ? `for ${brief.readers}` : ''].filter(Boolean).join(' · ') || 'Report',
|
|
687
|
+
title: titleText, sub: brief ? periodOf(brief, R) : '', data: agenda.length ? { lead: clip(r(report.answer.text), 200), cols: agenda } : { text: r(report.answer.text) }, source: srcOf(tokensIn(report.title).concat(tokensIn(report.answer.text))) });
|
|
688
|
+
const supports = (report.answer.supports || []).map((s) => { const t = r(s); const m = /^([^:]{3,80}):\s+(.+)$/.exec(t); return m ? { title: m[1], text: m[2].charAt(0).toUpperCase() + m[2].slice(1) } : { title: t, text: '' }; });
|
|
689
|
+
const ask = report.ask ? { question: r(report.ask.text), who: report.ask.who ? r(report.ask.who) : (brief && brief.readers) || '', by: report.ask.by || null, options: (report.ask.options || []).map(r) } : null;
|
|
690
|
+
const askInsight = ask ? { head: 'Your call', text: ask.question, points: [], note: [ask.who ? `For ${ask.who}` : '', ask.by ? `by ${full(ask.by)}` : ''].filter(Boolean).join(' · ') } : null;
|
|
691
|
+
slides.push({ kind: 'answer', layout: supports.length ? 'agenda' : 'statement', eyebrow: 'The answer', title: clip(r(report.answer.text), 200), sub: '',
|
|
692
|
+
data: supports.length ? { rows: supports.slice(0, 5) } : { text: r(report.answer.text) }, insight: askInsight,
|
|
660
693
|
source: srcOf(tokensIn(report.answer.text).concat(...(report.answer.supports || []).map(tokensIn))) });
|
|
661
694
|
for (const s of report.sections) {
|
|
695
|
+
if (s.layout && !RS.layoutError(s.layout, s.data)) {
|
|
696
|
+
// the writer bound this section to a library layout: its texts are resolved here, its insight goes in the side column
|
|
697
|
+
const data = RS.mapLayout(s.layout, s.data, r);
|
|
698
|
+
const ins = isObj(s.insight) ? { head: r(str(s.insight.head)), text: r(str(s.insight.text)), points: (Array.isArray(s.insight.points) ? s.insight.points : []).map((x) => ({ tone: x.tone, text: r(str(x.text)) })), note: r(str(s.insight.note)) } : null;
|
|
699
|
+
const ids = RS.textsOfLayout(s.layout, s.data).concat(textsOfInsight(s.insight)).flatMap(tokensIn);
|
|
700
|
+
slides.push({ kind: 'section', layout: s.layout, eyebrow: s.eyebrow ? r(s.eyebrow) : '', title: clip(r(s.title), 160), sub: s.sub ? r(s.sub) : '', data, insight: ins, source: srcOf(ids) });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
662
703
|
// a section goes on as many slides as its blocks need: a long table is cut at 8 rows, and a block that would run under the footer starts a new slide
|
|
663
704
|
const chunks = [[]]; let used = 0;
|
|
664
705
|
const put = (b) => { const h = blockHeight(b); if (chunks[chunks.length - 1].length && used + h > SLIDE_ROOM) { chunks.push([]); used = 0; } chunks[chunks.length - 1].push(b); used += h; };
|
|
665
|
-
for (const b of s.blocks) {
|
|
706
|
+
for (const b of s.blocks || []) {
|
|
666
707
|
if (b.kind === 'table' && b.rows.length > 8) for (let i = 0; i < b.rows.length; i += 8) put({ ...b, rows: b.rows.slice(i, i + 8) });
|
|
667
708
|
else put(b);
|
|
668
709
|
}
|
|
669
710
|
const real = chunks.filter((c) => c.length);
|
|
670
711
|
real.forEach((blocks, i) => {
|
|
671
712
|
const ids = blocks.flatMap((b) => textsOf({ title: '', answer: { text: '' }, sections: [{ title: '', blocks: [b] }] }).flatMap((t) => tokensIn(t.text)));
|
|
672
|
-
slides.push({ kind: 'section', title: clip(r(s.title), 160) + (i ? ' (continued)' : ''), blocks: blocks.map((b) => drawBlock(b, r, byId)), source: srcOf(ids) });
|
|
713
|
+
slides.push({ kind: 'section', layout: 'blocks', eyebrow: '', title: clip(r(s.title), 160) + (i ? ' (continued)' : ''), sub: '', data: { blocks: blocks.map((b) => drawBlock(b, r, byId)) }, blocks: blocks.map((b) => drawBlock(b, r, byId)), source: srcOf(ids) });
|
|
673
714
|
});
|
|
674
715
|
}
|
|
675
716
|
const parts = beyondParts(report);
|
|
676
717
|
for (let i = 0; i < parts.length; i += 4) {
|
|
677
718
|
const chunk = parts.slice(i, i + 4);
|
|
678
719
|
// the title is a finding, as on every slide: the first part's own sentence
|
|
679
|
-
slides.push({ kind: 'beyond',
|
|
720
|
+
slides.push({ kind: 'beyond', layout: 'agenda', eyebrow: 'Beyond the question', title: clip(r(chunk[0].texts[0]), 160), sub: '',
|
|
721
|
+
data: { rows: chunk.map((p) => ({ title: p.label, text: clip(p.texts.map(r).join(' '), 260) })) }, items: chunk.map((p) => ({ head: p.label, text: clip(p.texts.map(r).join(' '), 260) })),
|
|
680
722
|
source: srcOf(chunk.flatMap((p) => p.texts.flatMap(tokensIn))) });
|
|
681
723
|
}
|
|
682
|
-
if (ask && (ask.options.length || ask.who))
|
|
724
|
+
if (ask && (ask.options.length || ask.who)) {
|
|
725
|
+
const rows = ask.options.length ? ask.options.slice(0, 3).map((o, i) => ({ question: `Option ${'ABC'[i]}: ${o}`, who: ask.who, by: ask.by || undefined })) : [{ question: ask.question, who: ask.who, by: ask.by || undefined }];
|
|
726
|
+
slides.push({ kind: 'ask', layout: 'decisions', eyebrow: 'Needs your call', title: clip(ask.by ? `Decide by ${full(ask.by)}: ${ask.question.replace(/\?$/, '')}` : ask.question, 200), sub: ask.options.length ? ask.question : '',
|
|
727
|
+
data: { rows }, source: 'Source: the readings above' });
|
|
728
|
+
}
|
|
683
729
|
const names = new Map(((brief && brief.sources) || []).map((x) => [x.id, x.name]));
|
|
684
730
|
const used = new Set(textsOf(report).flatMap((t) => tokensIn(t.text)));
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
|
|
731
|
+
const srcIds = [...new Set(R.filter((x) => used.has(x.id)).map((x) => x.source))];
|
|
732
|
+
const lastAt = (s) => R.filter((x) => x.source === s && used.has(x.id)).map((x) => x.at).filter((a) => /^\d{4}-\d{2}-\d{2}$/.test(str(a))).sort().pop();
|
|
733
|
+
const read = srcIds.map((s) => ({ name: names.get(s) || s, ...(lastAt(s) ? { at: lastAt(s) } : {}) }));
|
|
734
|
+
const nr = (Array.isArray(readings && readings.gaps) ? readings.gaps : []).map((g) => ({ name: names.get(g.source) || g.source, why: str(g.reason) }));
|
|
735
|
+
slides.push({ kind: 'sources', layout: 'sources', eyebrow: 'Sources', title: `${read.length ? `Every number comes from ${read.length === 1 ? 'one source' : `${read.length} sources`}` : 'No reading is used on these slides'}${nr.length ? `; ${nr.length === 1 ? 'one source was' : `${nr.length} sources were`} not reached` : ''}`, sub: '',
|
|
736
|
+
data: { read: read.slice(0, 6), missing: nr.slice(0, 6) }, reached: read.map((x) => x.name + (x.at ? ', read ' + full(x.at) : '')), notReached: nr.map((x) => ({ source: x.name, why: x.why })), source: 'Source: the readings of this report' });
|
|
688
737
|
slides.forEach((s, i) => { s.n = i + 1; });
|
|
689
|
-
return { slides, look: look || null, name: r(report.title), engine: true };
|
|
738
|
+
return { slides, look: look || null, name: r(report.title), deck: deckName, today: (brief && brief.slot) || null, engine: true, frame: 'b2' };
|
|
690
739
|
}
|
|
740
|
+
function textsOfInsight(ins) { return isObj(ins) ? [ins.head, ins.text, ins.note].concat((Array.isArray(ins.points) ? ins.points : []).map((x) => x && x.text)).filter(isStr) : []; }
|
|
691
741
|
/** The room under a slide's title, and what a block takes of it (1280 x 720 frames, lib/report-pyramid.js css). */
|
|
692
742
|
const SLIDE_ROOM = 440;
|
|
693
743
|
function blockHeight(b) {
|
|
@@ -748,7 +798,7 @@ function write(root, id, slot, report, { now = Date.now() } = {}) {
|
|
|
748
798
|
const pre = checkReport(report, { brief, readings });
|
|
749
799
|
const hardFail = pre.filter((c) => c.hard && !c.pass);
|
|
750
800
|
if (hardFail.length) return refuse(422, hardFail.map((c) => `${c.id}: ${c.reason}`).join(' · '), { checks: pre });
|
|
751
|
-
const clean = { ...report, sections: report.sections.map((s) => ({ ...s, id: str(s.id) || slugOf(s.title) })), carried: brief.carried_asks || [], v: 1, at: new Date(now).toISOString() };
|
|
801
|
+
const clean = { ...report, sections: report.sections.map((s) => ({ ...s, blocks: Array.isArray(s.blocks) ? s.blocks : [], id: str(s.id) || slugOf(s.title) })), carried: brief.carried_asks || [], v: 1, at: new Date(now).toISOString() };
|
|
752
802
|
const md = renderMarkdown(clean, { brief, readings, now });
|
|
753
803
|
const checks = checkReport(clean, { brief, readings, markdown: md });
|
|
754
804
|
writeJson(P.report.abs, clean);
|
|
@@ -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>')
|