create-pmos 0.53.1 → 0.53.2

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/REMOTE.md +87 -6
  7. package/workspace/first-paint-audit.js +6 -2
  8. package/workspace/fixtures/initiatives-api/list.json +13 -0
  9. package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
  10. package/workspace/fixtures/report-api/draft.json +105 -0
  11. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
  12. package/workspace/guide/index.md +1 -0
  13. package/workspace/guide/initiatives-roadmap.md +1 -0
  14. package/workspace/guide/initiatives-screen.md +1 -0
  15. package/workspace/guide/layout.md +3 -2
  16. package/workspace/guide/report-slides.md +28 -0
  17. package/workspace/guide/reports.md +2 -0
  18. package/workspace/lib/auto-sync.js +83 -0
  19. package/workspace/lib/remote-access.js +96 -16
  20. package/workspace/lib/report-draft.js +33 -4
  21. package/workspace/lib/report-slides-data.js +141 -0
  22. package/workspace/lib/routes-initiatives.js +35 -2
  23. package/workspace/lib/routes-reports.js +3 -3
  24. package/workspace/lib/sync.js +61 -6
  25. package/workspace/package-lock.json +1 -1
  26. package/workspace/package.json +1 -1
  27. package/workspace/public/pm/app.js +16 -1
  28. package/workspace/public/pm/components.css +783 -0
  29. package/workspace/public/pm/dest-reports.js +8 -2
  30. package/workspace/public/pm/initiative-list.js +18 -1
  31. package/workspace/public/pm/initiatives-copy.js +4 -0
  32. package/workspace/public/pm/print.css +11 -0
  33. package/workspace/public/pm/report-slides.js +438 -0
  34. package/workspace/public/pm/report-view.js +8 -2
  35. package/workspace/public/pm/reports-copy.js +3 -0
  36. package/workspace/public/pm/roadmap.js +23 -0
  37. package/workspace/public/pm/slides-copy.js +52 -0
  38. package/workspace/public/pm/tokens.css +13 -0
  39. package/workspace/public/pm/topbar.js +5 -2
  40. package/workspace/replaced-tests.json +23 -1
  41. package/workspace/server.js +33 -13
  42. package/workspace/skills/report-biweekly.skill +1 -0
  43. package/workspace/test-files.json +2 -0
  44. package/workspace/test-lists/run6.json +9 -0
  45. package/workspace/test-lists/workspace-public-host.json +5 -0
@@ -23,7 +23,8 @@ import { pmGet, pmPost } from './http.js';
23
23
  import { watchSig } from './sig-watch.js';
24
24
  import { wsOf } from './data.js';
25
25
  import { COPY, cadenceName } from './reports-copy.js';
26
- import { renderReport, openIssueSheet, openNotesSheet, draftNow, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
26
+ import { openIssueSheet, openNotesSheet, draftNow, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
27
+ import { openSlides, slideCards } from './report-slides.js';
27
28
 
28
29
  const SHOWN = 4; // the reports listed before "Show older"
29
30
  const insts = new WeakMap(); // host -> instance
@@ -227,6 +228,10 @@ function viewerBlock(inst) {
227
228
  const bar = el('div', { class: 'rp-toolbar', role: 'toolbar', 'aria-label': COPY.reportName(r.cadence, r.slot) });
228
229
  bar.appendChild(el('h3', { class: 'rp-section__title rp-toolbar__title' }, COPY.reportName(r.cadence, r.slot)));
229
230
  const acts = el('div', { class: 'rp-toolbar__acts' });
231
+ // Present: the slides full screen (Run 6, R22.7); it reads the file and writes nothing
232
+ const present = button({ label: COPY.present, variant: 'outline', size: 'sm', icon: 'columns-2', attrs: { 'data-rp-act': 'present', 'data-rp-key': 'present' }, disabled: !doc,
233
+ onClick: () => doc && openSlides({ md: doc.markdown, label: COPY.reportName(r.cadence, r.slot), printable: !!r.issued, returnFocus: present }) });
234
+ acts.appendChild(present);
230
235
  if (r.issued) {
231
236
  acts.appendChild(button({ label: COPY.print, variant: 'outline', size: 'sm', attrs: { 'data-rp-act': 'print', 'data-rp-key': 'print' }, disabled: !doc, onClick: () => doc && printReport(doc.markdown) }));
232
237
  acts.appendChild(button({ label: COPY.copyMd, variant: 'outline', size: 'sm', attrs: { 'data-rp-act': 'copy', 'data-rp-key': 'copy' }, disabled: !doc, onClick: () => doc && copyMarkdown(doc.markdown) }));
@@ -246,7 +251,8 @@ function viewerBlock(inst) {
246
251
  bar.appendChild(acts);
247
252
  sec.appendChild(bar);
248
253
  if (r.issued) sec.appendChild(el('p', { class: 'rp-viewer__state' }, r.edited ? COPY.editedWhy : COPY.frozen));
249
- sec.appendChild(doc ? renderReport(doc.markdown) : el('p', { class: 'rp-section__empty' }, COPY.loading));
254
+ // the report is read as its slides (Run 6): the PM-OS deck, scaled to the column
255
+ sec.appendChild(doc ? slideCards(doc.markdown) : el('p', { class: 'rp-section__empty' }, COPY.loading));
250
256
  return sec;
251
257
  }
252
258
 
@@ -23,6 +23,7 @@ import { watchSig } from './sig-watch.js';
23
23
  import { COPY, shortDate } from './initiatives-copy.js';
24
24
  import { getList, sigKey, place, placed, leaveNow, setLifecycleBulk } from './initiatives-api.js';
25
25
  import { healthSignal, say, confirmSheet, checkBySheet, onContextMenu } from './initiatives-ui.js';
26
+ import { openDoc } from './panel-initiatives.js';
26
27
 
27
28
  const ORDER = { now: 0, next: 1, later: 2 };
28
29
  const MAX_BULK = 20;
@@ -143,6 +144,20 @@ export function mountList(host, env) {
143
144
  el('h3', { class: 'in-group__head' }, head), g.anchors.map(anchorEl));
144
145
  }
145
146
 
147
+ // The units with documents and no record (routes-initiatives.js unrecordedOf), last: they have no anchor to group by.
148
+ function unrecordedEl(list) {
149
+ return el('section', { class: 'in-group', 'data-in-group': 'unrecorded' },
150
+ el('h3', { class: 'in-group__head' }, el('span', { class: 'in-group__name' }, COPY.noRecordGroup)),
151
+ el('div', { class: 'in-rows', role: 'list' }, list.map((u) => {
152
+ const doc = u.artifacts.find((a) => a.role === 'PRD') || u.artifacts[0];
153
+ return row({
154
+ title: u.title, meta: [COPY.noRecord],
155
+ onOpen: () => openDoc(null, doc.path, doc.role || 'DOC'), openLabel: COPY.aria.open(u.title),
156
+ attrs: { class: 'in-row', 'data-in-unrecorded-row': u.id }, openAttrs: { 'data-in-unrecorded-open': u.id },
157
+ });
158
+ })));
159
+ }
160
+
146
161
  function bulkBar() {
147
162
  const n = picked.size;
148
163
  const can = env.grants().includes('lifecycle-bulk');
@@ -167,12 +182,14 @@ export function mountList(host, env) {
167
182
  return;
168
183
  }
169
184
  const kids = [];
170
- if (!allRows().length) kids.push(empty({ text: COPY.emptyAll }));
185
+ const unrecorded = data.unrecorded || [];
186
+ if (!allRows().length && !unrecorded.length) kids.push(empty({ text: COPY.emptyAll }));
171
187
  else {
172
188
  kids.push(el('p', { class: 'in-note in-hint', 'data-in-hint': '' }, COPY.menuHintRow));
173
189
  kids.push(el('div', { class: 'in-toolbar' }, selecting ? bulkBar() : button({ label: COPY.select, variant: 'ghost', size: 'sm', onClick: () => { selecting = true; render(); }, attrs: { 'data-in-select-mode': '' } })));
174
190
  kids.push(...data.groups.map(groupEl));
175
191
  }
192
+ if (unrecorded.length) kids.push(unrecordedEl(unrecorded));
176
193
  root.replaceChildren(...kids);
177
194
  if (active) { const t = root.querySelector('[data-in-open="' + CSS.escape(active) + '"], [data-in-select="' + CSS.escape(active) + '"]'); if (t) t.focus({ preventScroll: true }); }
178
195
  }
@@ -41,6 +41,10 @@ export const COPY = {
41
41
  emptyColumn: { now: 'Nothing in Now.', next: 'Nothing in Next.', later: 'Nothing in Later.' },
42
42
  dormant: (n) => `${n} dormant`,
43
43
  hideDormant: (n) => `Hide ${n} dormant`,
44
+ unrecorded: (n) => `${n} with no record`,
45
+ hideUnrecorded: (n) => `Hide ${n} with no record`,
46
+ noRecord: 'no record yet',
47
+ noRecordGroup: 'No record yet',
44
48
  unconfirmed: 'unconfirmed',
45
49
  checkBy: (d) => `check-by ${d}`,
46
50
  waitingYou: 'waiting on you',
@@ -5,6 +5,11 @@
5
5
  @page {
6
6
  margin: 16mm;
7
7
  }
8
+ /* the slides (Run 6): each slide is one 1280 × 720 px page (13.333in × 7.5in), 16:9, with no margin */
9
+ @page slides {
10
+ size: 13.3333in 7.5in;
11
+ margin: 0;
12
+ }
8
13
  @media print {
9
14
  body > *:not(.rp-print) {
10
15
  display: none !important;
@@ -30,4 +35,10 @@
30
35
  .rp-print .rp-doc__note {
31
36
  break-inside: avoid;
32
37
  }
38
+ .rp-print--slides .rp-slide {
39
+ page: slides;
40
+ break-after: page;
41
+ print-color-adjust: exact;
42
+ -webkit-print-color-adjust: exact;
43
+ }
33
44
  }
@@ -0,0 +1,438 @@
1
+ /* pm/report-slides.js — the biweekly as slides (pm-os-absorb Run 6; PRD R22.7, open question Q3 answered "slides").
2
+ *
3
+ * The PM asked for the PM-OS biweekly deck, cut to four kinds of slide (2026-10-06): the roadmap snapshot, the
4
+ * initiative list, one slide per initiative and the changelog. Each is drawn on the PM-OS canvas: 1280 × 720, a
5
+ * numbered blue header band, a footer with the page number and the report's name.
6
+ *
7
+ * The slides read the report file and nothing else. The drafter (lib/report-draft.js) folds the facts into one
8
+ * `<!-- pmos:slides v1 {json} -->` block at the end of the draft, so an issued report freezes its slides with its
9
+ * text. The PM's words are the note markers: an initiative's note is the box on its slide, and `report.changelog` is
10
+ * the release notes. This file computes no fact and writes nothing.
11
+ * slidesOf(md) the report as a list of slides (pure, no DOM); [] when the file has no slides block
12
+ * renderSlide(s) one slide as DOM nodes, at 1280 × 720
13
+ * slideCards(md) every slide, scaled to the width of the Reports viewer
14
+ * openSlides({ md }) Present: full screen on black; arrow keys, Space, PageUp/PageDown, Home/End, a swipe, Escape
15
+ * printSlides(md) every slide on its own 1280 × 720 page (print.css), then window.print()
16
+ */
17
+
18
+ import { el } from './dom.js';
19
+ import { button, iconButton, layer, ensureComponentsCss } from './primitives.js';
20
+ import { icon, ensureSprite } from './icons.js';
21
+ import { parseReport, ensurePrintCss, clearPrint } from './report-view.js';
22
+ import { COPY } from './slides-copy.js';
23
+
24
+ export const W = 1280; // the PM-OS slide canvas, in CSS px (13.333in × 7.5in on paper)
25
+ export const H = 720;
26
+ const ROADMAP_LINES = 9; // group and initiative rows on one roadmap slide (52px each)
27
+ const LIST_ROWS = 10; // initiatives on one list slide (49px each)
28
+ const COLUMN_CAP = 6; // items in a Last / Next cycle column before "+N more"
29
+ const NOTE_LINES = 4; // lines in an initiative's note box
30
+ const CHANGELOG_BUDGET = 560; // px of release notes on one changelog slide
31
+ const SLIDES_RE = /<!-- pmos:slides v1 (\{[^\n]*\}) -->\s*$/; // the drafter's block: one line, last in the file
32
+ const STAGE_TONE = { intake: 'intake', brief: 'intake', spec: 'spec', draft: 'spec', contract: 'spec', build: 'build', evaluate: 'evaluate', handoff: 'handoff' };
33
+ const EARLY = /^(intake|brief|spec|draft|contract|unknown)$/i; // a lifecycle badge before the build reads muted
34
+ const TYPES = ['launch', 'improvement', 'fix', 'security', 'deprecation', 'beta'];
35
+ const KINDS = ['added', 'changed', 'fixed', 'deprecated', 'security', 'removed'];
36
+
37
+ /* ── dates, the PM-OS way ─────────────────────────────────────────────────────────────────────────────────────────── */
38
+
39
+ const utc = (ymd) => new Date(String(ymd).slice(0, 10) + 'T00:00:00Z');
40
+ const fmt = (ymd, opts, loc = 'en-GB') => utc(ymd).toLocaleDateString(loc, { timeZone: 'UTC', ...opts });
41
+ const dayBefore = (ymd) => new Date(utc(ymd).getTime() - 86400000).toISOString().slice(0, 10);
42
+ /** "10 Jun 26" */
43
+ export const msDate = (ymd) => (ymd ? fmt(ymd, { day: 'numeric', month: 'short', year: '2-digit' }) : '');
44
+ /** "28 Sept → 4 Oct"; inside one month "5 Oct → 11". Both ends are included. */
45
+ export function rangeText(from, to) {
46
+ if (!from || !to) return '';
47
+ const a = fmt(from, { day: 'numeric', month: 'short' });
48
+ return from.slice(0, 7) === to.slice(0, 7) ? a + ' → ' + Number(to.slice(8, 10)) : a + ' → ' + fmt(to, { day: 'numeric', month: 'short' });
49
+ }
50
+ const longDate = (ymd) => fmt(ymd, { day: 'numeric', month: 'long', year: 'numeric' });
51
+ const monthName = (ym) => fmt(ym + '-01', { month: 'short' }, 'en-US');
52
+ const monthTitle = (ymd) => fmt(ymd, { month: 'long', year: 'numeric' }, 'en-US');
53
+
54
+ /* ── the report as slides (pure) ──────────────────────────────────────────────────────────────────────────────────── */
55
+
56
+ /** The slides block of the report, or null when the file has none (a draft from before Run 6, or a hand-written one). */
57
+ export function slidesBlock(md) {
58
+ const m = SLIDES_RE.exec(String(md || '').replace(/\r\n/g, '\n'));
59
+ if (!m) return null;
60
+ try { const d = JSON.parse(m[1]); return d && d.v === 1 ? d : null; } catch { return null; }
61
+ }
62
+
63
+ /** The release notes the PM writes in `report.changelog`. Entries are separated by a blank line:
64
+ * Launch · Sign-in: Email codes for every title (2026-10-02)
65
+ * Readers can sign in with a one-time code.
66
+ * - Added: Code entry screen
67
+ * The first line is `Type[ · Area]: Title[ (YYYY-MM-DD)]`; a plain line is the summary; `- Kind: text` is a change. */
68
+ export function parseChangelog(text, fallbackDate) {
69
+ // Line by line, not paragraph by paragraph: md-newlines --fix joins a paragraph into one line and puts a blank line
70
+ // before a list, so an entry must read the same with or without blank lines. An entry starts on a typed head line
71
+ // ("Launch · Area: Title"), or on a plain line after a blank line once the entry before has a summary or a change.
72
+ const out = [];
73
+ let e = null; let gap = true;
74
+ for (const raw of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
75
+ const ln = raw.trim().replace(/^#{1,6}\s+/, '');
76
+ if (!ln) { gap = true; continue; }
77
+ const head = /^([A-Za-z]+)(?:\s*·\s*([^:]+?))?\s*:\s*(.+?)(?:\s*\((\d{4}-\d{2}-\d{2})\))?$/.exec(ln);
78
+ const c = /^-\s+([A-Za-z]+)\s*:\s*(.+)$/.exec(ln);
79
+ const item = /^-\s+/.test(ln);
80
+ if (head && !item && TYPES.includes(head[1].toLowerCase())) {
81
+ e = { type: head[1].toLowerCase(), area: head[2] ? head[2].trim() : '', title: head[3].trim(), date: head[4] || fallbackDate, summary: [], changes: [] };
82
+ out.push(e);
83
+ } else if (!e || (!item && gap && (e.summary.length || e.changes.length))) {
84
+ e = { type: 'improvement', area: '', title: ln.replace(/^-\s+/, ''), date: fallbackDate, summary: [], changes: [] };
85
+ out.push(e);
86
+ } else if (c && KINDS.includes(c[1].toLowerCase())) e.changes.push({ kind: c[1].toLowerCase(), text: c[2].trim() });
87
+ else if (item) e.changes.push({ kind: 'changed', text: ln.replace(/^-\s+/, '') });
88
+ else e.summary.push(ln);
89
+ gap = false;
90
+ }
91
+ for (const x of out) x.summary = x.summary.join(' ');
92
+ return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
93
+ }
94
+
95
+ /** Roadmap lines, nine to a slide: a slide never ends on a group label, and a group that runs on repeats its label. */
96
+ function roadmapPages(lines) {
97
+ const pages = [[]];
98
+ for (const ln of lines) {
99
+ let cur = pages[pages.length - 1];
100
+ const room = ROADMAP_LINES - cur.length;
101
+ if (room <= 0 || (!ln.row && room <= 1)) { cur = []; pages.push(cur); if (ln.row) cur.push({ group: ln.group }); }
102
+ cur.push(ln);
103
+ }
104
+ return pages;
105
+ }
106
+
107
+ const chunks = (xs, n) => { const out = []; for (let i = 0; i < xs.length; i += n) out.push(xs.slice(i, i + n)); return out.length ? out : [[]]; };
108
+
109
+ /** The height one release-notes entry takes, near enough to fill a slide without running off it. */
110
+ const entryHeight = (e) => 20 + 30 + Math.ceil((e.summary || '').length / 150) * 23 + e.changes.length * 24 + 28;
111
+
112
+ /** "northwind | Biweekly · 16 Oct → 29 | 16 October 2026" */
113
+ function footOf(meta) {
114
+ return meta.product + ' | ' + meta.cadence + ' · ' + rangeText(meta.next.from, dayBefore(meta.next.to)) + ' | ' + longDate(meta.slot || meta.today);
115
+ }
116
+
117
+ /** The report as slides. Pure: the same file gives the same slides. Each slide carries its own number. */
118
+ export function slidesOf(md) {
119
+ const d = slidesBlock(md);
120
+ if (!d) return [];
121
+ const notes = new Map(parseReport(md).filter((b) => b.t === 'note').map((b) => [b.id, b.text.trim()]));
122
+ const slides = [];
123
+
124
+ // 1. the roadmap snapshot: group label lines and initiative lines, nine to a slide
125
+ const lines = [];
126
+ for (const g of d.roadmap.groups) { lines.push({ group: g.name }); for (const r of g.rows) lines.push({ group: g.name, row: r }); }
127
+ for (const part of roadmapPages(lines)) slides.push({ kind: 'roadmap', title: COPY.roadmap, months: d.roadmap.months, today: d.roadmap.today, lines: part });
128
+
129
+ // 2. the initiative list, ten to a slide
130
+ for (const part of chunks(d.list, LIST_ROWS)) slides.push({ kind: 'list', title: COPY.list, rows: part, today: d.meta.today });
131
+
132
+ // 3. one slide per initiative, its note in the box
133
+ for (const x of d.initiatives) slides.push({ kind: 'initiative', title: x.title, init: x, note: notes.get(x.id) || '', meta: d.meta });
134
+
135
+ // 4. the changelog: the PM's own release notes, typed into the report.changelog note; nothing written, nothing shown
136
+ const entries = parseChangelog(notes.get('report.changelog'), d.meta.today);
137
+ const pages = [[]];
138
+ let used = 0;
139
+ for (const e of entries) {
140
+ const h = entryHeight(e) + 36;
141
+ if (used + h > CHANGELOG_BUDGET && pages[pages.length - 1].length) { pages.push([]); used = 0; }
142
+ pages[pages.length - 1].push(e); used += h;
143
+ }
144
+ for (const part of pages) slides.push({ kind: 'changelog', title: COPY.changelog, entries: part });
145
+
146
+ slides.forEach((s, i) => { s.n = i + 1; s.foot = footOf(d.meta); });
147
+ return slides;
148
+ }
149
+
150
+ /* ── one slide as DOM ─────────────────────────────────────────────────────────────────────────────────────────────── */
151
+
152
+ const pad2 = (n) => String(n).padStart(2, '0');
153
+ const span = (cls, text) => el('span', { class: cls }, text);
154
+ const glyph = (name, cls) => el('span', { class: cls, 'aria-hidden': 'true' }, icon(name));
155
+ /** Run-time geometry, as the layout's own custom properties (R28-1). */
156
+ function placed(node, vars) { for (const [k, v] of Object.entries(vars)) node.style.setProperty(k, v); return node; }
157
+
158
+ function frame(s, body) {
159
+ return el('section', { class: 'rp-slide rp-slide--' + s.kind, 'data-slide': s.kind, 'aria-roledescription': 'slide', 'aria-label': s.title },
160
+ el('header', { class: 'rp-slide__header' },
161
+ el('div', { class: 'rp-slide__num' }, pad2(s.n)),
162
+ el('div', { class: 'rp-slide__band' }, el('h2', { class: 'rp-slide__title' }, s.title))),
163
+ el('div', { class: 'rp-slide__body' }, body),
164
+ el('footer', { class: 'rp-slide__footer' },
165
+ span('rp-slide__page', pad2(s.n)), span('rp-slide__meta', s.foot)));
166
+ }
167
+
168
+ /* the roadmap: a 300px column of names, then six months of bars */
169
+ const MONTH_W = (1212 - 300) / 6;
170
+ function xOf(months, ymd) {
171
+ const ym = ymd.slice(0, 7);
172
+ const k = months.indexOf(ym);
173
+ if (k < 0) return ym < months[0] ? -8 : months.length * MONTH_W + 8;
174
+ const days = new Date(Date.UTC(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0)).getUTCDate();
175
+ return (k + (Number(ymd.slice(8, 10)) - 1) / days) * MONTH_W;
176
+ }
177
+ const DAY_W = MONTH_W / 30;
178
+ const pos = (left, width) => ({ '--rp-x': Math.round(left) + 'px', '--rp-w': Math.max(4, Math.round(width)) + 'px' });
179
+
180
+ function roadmapBody(s) {
181
+ const names = el('div', { class: 'rp-slide__gnames' },
182
+ el('div', { class: 'rp-slide__ghead' }, el('p', { class: 'rp-slide__gissues' }, COPY.issues), el('p', { class: 'rp-slide__gdur' }, COPY.duration)));
183
+ const lanes = el('div', { class: 'rp-slide__glanes' },
184
+ s.months.map((ym, k) => placed(el('div', { class: 'rp-slide__gcol' },
185
+ k === 0 || ym.endsWith('-01') ? el('p', { class: 'rp-slide__gyear' }, ym.slice(0, 4)) : null,
186
+ el('p', { class: 'rp-slide__gmonth' }, monthName(ym))), pos(k * MONTH_W, MONTH_W))));
187
+ const rows = el('div', { class: 'rp-slide__grows' });
188
+ lanes.appendChild(rows);
189
+ for (const ln of s.lines) {
190
+ if (!ln.row) {
191
+ names.appendChild(el('p', { class: 'rp-slide__glabel' }, ln.group));
192
+ rows.appendChild(el('div', { class: 'rp-slide__gline' }));
193
+ continue;
194
+ }
195
+ const r = ln.row;
196
+ names.appendChild(el('div', { class: 'rp-slide__gname', 'data-init': r.id },
197
+ glyph('chevron-right', 'rp-slide__gchev'),
198
+ el('span', { class: 'rp-slide__ldot rp-slide__ldot--' + ln.group.toLowerCase() }),
199
+ el('span', { class: 'rp-slide__gtitle' }, r.title)));
200
+ const line = el('div', { class: 'rp-slide__gline', 'data-init': r.id });
201
+ if (r.segments.length) {
202
+ const x0 = xOf(s.months, r.segments.reduce((a, g) => (g.start < a ? g.start : a), r.segments[0].start));
203
+ const x1 = xOf(s.months, r.segments.reduce((a, g) => (g.due > a ? g.due : a), r.segments[0].due)) + DAY_W;
204
+ const bar = placed(el('div', { class: 'rp-slide__gbar' }), pos(x0 - 6, x1 - x0 + 12));
205
+ for (const g of r.segments) {
206
+ const a = xOf(s.months, g.start); const b = xOf(s.months, g.due) + DAY_W;
207
+ bar.appendChild(placed(el('span', {
208
+ class: 'rp-slide__gseg rp-slide__gseg--' + (STAGE_TONE[g.stage] || 'other') + (r.estimated ? ' rp-slide__gseg--est' : ''),
209
+ 'data-stage': g.label, title: g.label + ' · ' + msDate(g.start) + ' → ' + msDate(g.due),
210
+ }), pos(a - x0 + 6, b - a)));
211
+ }
212
+ line.appendChild(bar);
213
+ }
214
+ rows.appendChild(line);
215
+ }
216
+ return el('div', { class: 'rp-slide__gantt' }, names, lanes);
217
+ }
218
+
219
+ /* the initiative list */
220
+ const badge = (status) => el('span', { class: 'rp-slide__badge' + (EARLY.test(status || '') ? ' rp-slide__badge--early' : '') }, status || '');
221
+ const dash = () => span('rp-slide__dash', '—');
222
+ function milestoneCell(m, today) {
223
+ if (!m) return el('div', { class: 'rp-slide__td rp-slide__td--ms' }, dash());
224
+ const late = m.due < today;
225
+ return el('div', { class: 'rp-slide__td rp-slide__td--ms' },
226
+ el('p', { class: 'rp-slide__mstitle' }, m.title),
227
+ el('p', { class: 'rp-slide__msdue' + (late ? ' rp-slide__msdue--late' : '') }, late ? COPY.overdue(msDate(m.due)) : msDate(m.due)));
228
+ }
229
+ function listBody(s) {
230
+ const head = el('div', { class: 'rp-slide__tr rp-slide__tr--head' },
231
+ el('div', { class: 'rp-slide__th' }, COPY.initiative), el('div', { class: 'rp-slide__th rp-slide__th--caps' }, COPY.status),
232
+ el('div', { class: 'rp-slide__th' }, COPY.nextMilestone), el('div', { class: 'rp-slide__th' }, COPY.release));
233
+ const rows = s.rows.map((x) => el('div', { class: 'rp-slide__tr', 'data-init': x.id },
234
+ el('div', { class: 'rp-slide__td rp-slide__td--name' }, x.title),
235
+ el('div', { class: 'rp-slide__td' }, badge(x.status)),
236
+ milestoneCell(x.next_milestone, s.today),
237
+ el('div', { class: 'rp-slide__td rp-slide__td--rel' }, x.release ? msDate(x.release) : dash())));
238
+ return el('div', { class: 'rp-slide__table' }, head, s.rows.length ? rows : el('p', { class: 'rp-slide__empty rp-slide__empty--list' }, COPY.noInitiatives));
239
+ }
240
+
241
+ /* one initiative */
242
+ function itemRow(it) {
243
+ const mark = it.milestone ? el('span', { class: 'rp-slide__mark rp-slide__mark--ms', 'aria-hidden': 'true' })
244
+ : it.done ? glyph('check', 'rp-slide__mark rp-slide__mark--done') : el('span', { class: 'rp-slide__mark rp-slide__mark--open', 'aria-hidden': 'true' });
245
+ const sub = it.milestone
246
+ ? [span('rp-slide__subms', COPY.milestone), it.date ? ' · ' + msDate(it.date) : '', it.owner ? ' · ' + it.owner : '']
247
+ : [it.stage || '', it.owner ? ' · ' + it.owner : ''];
248
+ return el('li', { class: 'rp-slide__item' + (it.milestone ? ' rp-slide__item--ms' : ''), 'data-done': it.done ? 'true' : 'false' }, mark,
249
+ el('div', { class: 'rp-slide__itext' }, el('p', { class: 'rp-slide__ititle' }, it.title), el('p', { class: 'rp-slide__isub' }, sub)));
250
+ }
251
+ function cycleColumn(key, title, range, items, empty) {
252
+ const sorted = [...items.filter((x) => x.milestone), ...items.filter((x) => !x.milestone)];
253
+ return el('div', { class: 'rp-slide__ccol', 'data-cycle': key },
254
+ el('div', { class: 'rp-slide__chead' }, el('h3', { class: 'rp-slide__ctitle' }, title), span('rp-slide__crange', range)),
255
+ sorted.length
256
+ ? el('ul', { class: 'rp-slide__items' }, sorted.slice(0, COLUMN_CAP).map(itemRow),
257
+ sorted.length > COLUMN_CAP ? el('li', { class: 'rp-slide__more' }, COPY.more(sorted.length - COLUMN_CAP)) : null)
258
+ : el('p', { class: 'rp-slide__empty' }, empty));
259
+ }
260
+ function noteBox(text) {
261
+ if (!text) return null;
262
+ const m = /^(note|info|warning)\s*:\s*/i.exec(text);
263
+ const type = m ? m[1].toLowerCase() : 'note';
264
+ const lines = text.slice(m ? m[0].length : 0).split('\n').map((l) => l.replace(/^[-*]\s+/, '').trim()).filter(Boolean);
265
+ return el('div', { class: 'rp-slide__note rp-slide__note--' + type, 'data-note-type': type },
266
+ el('p', { class: 'rp-slide__notehead' }, COPY.noteType[type]),
267
+ el('ul', { class: 'rp-slide__notelist' }, lines.slice(0, NOTE_LINES).map((l) => el('li', { class: 'rp-slide__noteli' }, l))));
268
+ }
269
+ function initiativeBody(s) {
270
+ const x = s.init; const m = s.meta;
271
+ const top = el('div', { class: 'rp-slide__itop' }, badge(x.status),
272
+ x.pct === null ? el('p', { class: 'rp-slide__pct rp-slide__pct--none' }, COPY.noBreakdown) : el('div', { class: 'rp-slide__prog' },
273
+ el('div', { class: 'rp-slide__track' }, placed(el('div', { class: 'rp-slide__fill' }), { '--rp-pct': x.pct + '%' })),
274
+ el('p', { class: 'rp-slide__pct' }, x.pct + '%')));
275
+ const ms = x.next_milestone;
276
+ const late = ms && ms.due < m.today;
277
+ const msLine = el('div', { class: 'rp-slide__msline' },
278
+ span('rp-slide__mslabel', COPY.nextMilestone),
279
+ ms ? span('rp-slide__msname', ms.title) : dash(),
280
+ ms ? span('rp-slide__mswhen' + (late ? ' rp-slide__mswhen--late' : ''), late ? COPY.overdue(msDate(ms.due)) : msDate(ms.due)) : null,
281
+ span('rp-slide__mslabel rp-slide__mslabel--rel', COPY.release),
282
+ x.release ? span('rp-slide__msname', msDate(x.release)) : dash());
283
+ return [top, msLine,
284
+ el('div', { class: 'rp-slide__cycles' },
285
+ cycleColumn('last', COPY.lastCycle, rangeText(m.last.from, dayBefore(m.last.to)), x.last, COPY.nothingShipped),
286
+ cycleColumn('next', COPY.nextCycle, rangeText(m.next.from, dayBefore(m.next.to)), x.next, COPY.nothingPlanned)),
287
+ noteBox(s.note)];
288
+ }
289
+
290
+ /* release notes */
291
+ function changelogBody(s) {
292
+ if (!s.entries.length) return el('p', { class: 'rp-slide__empty' }, COPY.noReleaseNotes);
293
+ const out = [];
294
+ let month = null; let list = null;
295
+ for (const e of s.entries) {
296
+ const ym = e.date.slice(0, 7);
297
+ if (ym !== month) {
298
+ month = ym;
299
+ const n = s.entries.filter((y) => y.date.slice(0, 7) === ym).length;
300
+ out.push(el('h3', { class: 'rp-slide__month' }, monthTitle(e.date), span('rp-slide__mcount', COPY.entries(n))));
301
+ list = el('ol', { class: 'rp-slide__timeline' });
302
+ out.push(list);
303
+ }
304
+ list.appendChild(el('li', { class: 'rp-slide__entry rp-slide__entry--' + e.type, 'data-entry-type': e.type },
305
+ el('span', { class: 'rp-slide__edot', 'aria-hidden': 'true' }),
306
+ el('div', { class: 'rp-slide__erow' },
307
+ span('rp-slide__pill', e.label || e.type),
308
+ e.area ? span('rp-slide__area', e.area) : null,
309
+ el('time', { class: 'rp-slide__edate', datetime: e.date }, fmt(e.date, { month: 'short', day: 'numeric', year: 'numeric' }, 'en-US'),
310
+ span('rp-slide__eday', ' · ' + fmt(e.date, { weekday: 'long' }, 'en-US')))),
311
+ el('h4', { class: 'rp-slide__etitle' }, e.title),
312
+ e.summary ? el('p', { class: 'rp-slide__esum' }, e.summary) : null,
313
+ e.changes.map((c) => el('p', { class: 'rp-slide__change' }, span('rp-slide__kind rp-slide__kind--' + c.kind, c.kind), span('rp-slide__ctext', c.text)))));
314
+ }
315
+ return out;
316
+ }
317
+
318
+ const BODY = { roadmap: roadmapBody, list: listBody, initiative: initiativeBody, changelog: changelogBody };
319
+
320
+ /** One slide, 1280 × 720. */
321
+ export function renderSlide(s) {
322
+ ensureSprite();
323
+ return frame(s, BODY[s.kind](s));
324
+ }
325
+
326
+ /* ── in the Reports viewer: the slides, one under the other, scaled to the column ────────────────────────────────── */
327
+
328
+ const fitTo = (host, width) => host.style.setProperty('--rp-scale', String(Math.max(0.05, Math.round((width / W) * 10000) / 10000)));
329
+
330
+ /** Every slide of the report as a card the width of the column. A file with no slides block says how to get them. */
331
+ export function slideCards(md) {
332
+ ensureComponentsCss();
333
+ const slides = slidesOf(md);
334
+ if (!slides.length) return el('p', { class: 'rp-section__empty', 'data-rp-noslides': '' }, COPY.slidesMissing);
335
+ const host = el('div', { class: 'rp-slides', role: 'list', 'aria-label': COPY.slidesTitle },
336
+ slides.map((s) => el('div', { class: 'rp-slides__card', role: 'listitem', 'data-slide-n': String(s.n) }, renderSlide(s))));
337
+ if (typeof ResizeObserver === 'function') {
338
+ const ro = new ResizeObserver((es) => { for (const e of es) if (e.contentRect.width) fitTo(host, e.contentRect.width); });
339
+ ro.observe(host);
340
+ }
341
+ return host;
342
+ }
343
+
344
+ /* ── on paper: one slide per 1280 × 720 page ──────────────────────────────────────────────────────────────────────── */
345
+
346
+ /** Draw every slide into `body > .rp-print.rp-print--slides` and print. print.css gives each slide its own page. */
347
+ export function printSlides(md) {
348
+ ensureComponentsCss();
349
+ ensurePrintCss();
350
+ clearPrint();
351
+ const host = el('div', { class: 'rp-print rp-print--slides', 'aria-hidden': 'true' }, slidesOf(md).map((s) => renderSlide(s)));
352
+ document.body.appendChild(host);
353
+ const drop = () => { host.remove(); window.removeEventListener('afterprint', drop); };
354
+ window.addEventListener('afterprint', drop);
355
+ try { window.print(); } catch { drop(); }
356
+ }
357
+
358
+ /* ── Present ──────────────────────────────────────────────────────────────────────────────────────────────────────── */
359
+
360
+ /**
361
+ * openSlides({ md, label, start?, printable?, returnFocus? }): the deck, full screen on black, one slide fitted. ← → Space PageUp PageDown Home
362
+ * End move; a horizontal swipe of 40px moves; Escape or Close ends it. Returns { dialog, go(i), close, index, count }.
363
+ */
364
+ export function openSlides({ md, label, start = 0, printable = false, returnFocus = null } = {}) {
365
+ ensureComponentsCss();
366
+ ensureSprite();
367
+ const slides = slidesOf(md);
368
+ let i = Math.min(Math.max(0, start), Math.max(0, slides.length - 1));
369
+ let closed = false;
370
+ const stage = el('div', { class: 'rp-deck__stage' });
371
+ const frameEl = el('div', { class: 'rp-deck__frame', 'aria-live': 'polite' });
372
+ stage.appendChild(frameEl);
373
+ const count = el('p', { class: 'rp-deck__count' });
374
+ const prev = iconButton({ icon: 'chevron-left', ariaLabel: COPY.slidesPrev, attrs: { 'data-rp-act': 'slide-prev' }, onClick: () => go(i - 1) });
375
+ const next = iconButton({ icon: 'chevron-right', ariaLabel: COPY.slidesNext, attrs: { 'data-rp-act': 'slide-next' }, onClick: () => go(i + 1) });
376
+ // Print / PDF only for an issued report: a report leaves the workspace after Issue, never as a draft (D71)
377
+ const print = printable ? button({ label: COPY.print, variant: 'ghost', size: 'sm', attrs: { 'data-rp-act': 'slides-print' }, onClick: () => printSlides(md) }) : null;
378
+ const shut = iconButton({ icon: 'x', ariaLabel: COPY.slidesClose, attrs: { 'data-rp-act': 'slides-close' }, onClick: () => close() });
379
+ const bar = el('div', { class: 'rp-deck__bar', role: 'toolbar', 'aria-label': label || COPY.slidesTitle },
380
+ el('p', { class: 'rp-deck__label' }, label || COPY.slidesTitle), prev, count, next, print, shut);
381
+ const dialog = el('dialog', { class: 'rp-deck', 'aria-modal': 'true', 'aria-label': label || COPY.slidesTitle, 'aria-roledescription': 'carousel', tabindex: '-1' }, stage, bar);
382
+
383
+ const fit = () => {
384
+ const r = stage.getBoundingClientRect();
385
+ const k = Math.max(0.05, Math.min(r.width / W, r.height / H));
386
+ dialog.style.setProperty('--rp-scale', String(Math.round(k * 10000) / 10000));
387
+ };
388
+ function go(to) {
389
+ if (!slides.length) {
390
+ frameEl.replaceChildren(el('p', { class: 'rp-deck__empty' }, COPY.slidesMissing));
391
+ count.textContent = ''; prev.disabled = true; next.disabled = true;
392
+ return;
393
+ }
394
+ i = Math.min(Math.max(0, to), slides.length - 1);
395
+ frameEl.replaceChildren(renderSlide(slides[i]));
396
+ count.textContent = COPY.slidesCount(i + 1, slides.length);
397
+ const was = document.activeElement;
398
+ prev.disabled = i === 0;
399
+ next.disabled = i === slides.length - 1;
400
+ // a button that was just disabled loses the focus to the page: keep it in the deck, so the keys still move
401
+ if ((was === prev && prev.disabled) || (was === next && next.disabled)) dialog.focus();
402
+ }
403
+ function close() {
404
+ if (closed) return;
405
+ closed = true;
406
+ window.removeEventListener('resize', fit);
407
+ try { if (dialog.open) dialog.close(); } catch { /* already closed */ }
408
+ dialog.remove();
409
+ if (returnFocus && returnFocus.isConnected) returnFocus.focus();
410
+ }
411
+ dialog.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
412
+ dialog.addEventListener('keydown', (e) => {
413
+ const k = e.key;
414
+ if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
415
+ else if (k === 'ArrowRight' || k === 'PageDown' || (k === ' ' && !(e.target && e.target.closest && e.target.closest('button')))) { e.preventDefault(); go(i + 1); }
416
+ else if (k === 'ArrowLeft' || k === 'PageUp') { e.preventDefault(); go(i - 1); }
417
+ else if (k === 'Home') { e.preventDefault(); go(0); }
418
+ else if (k === 'End') { e.preventDefault(); go(slides.length - 1); }
419
+ });
420
+ // a swipe: touch, not pointer, so a mouse drag selects text as usual; the buttons are the way for a mouse and a keyboard
421
+ let x0 = null; let y0 = null;
422
+ stage.addEventListener('touchstart', (e) => { const t = e.changedTouches[0]; x0 = t.clientX; y0 = t.clientY; }, { passive: true });
423
+ stage.addEventListener('touchend', (e) => {
424
+ if (x0 === null) return;
425
+ const t = e.changedTouches[0];
426
+ const dx = t.clientX - x0; const dy = t.clientY - y0;
427
+ x0 = null;
428
+ if (Math.abs(dx) > 40 && Math.abs(dx) > 1.5 * Math.abs(dy)) go(i + (dx < 0 ? 1 : -1));
429
+ }, { passive: true });
430
+
431
+ layer().appendChild(dialog);
432
+ dialog.showModal();
433
+ window.addEventListener('resize', fit);
434
+ fit();
435
+ go(i);
436
+ dialog.focus();
437
+ return { dialog, go, close, get index() { return i; }, get count() { return slides.length; } };
438
+ }
@@ -6,7 +6,8 @@
6
6
  * noteSlots(md) [{ id, title, text }] the notes the PM can edit, in the order they appear
7
7
  * markdownToCopy(md) the markdown without its frontmatter and note markers, for Copy as markdown
8
8
  * copyMarkdown(md) put that on the clipboard, then toast
9
- * printReport(md) draw the report into `body > .rp-print` and call window.print(); print.css shows only it
9
+ * printReport(md) print the report's slides (report-slides.js), or for a file with none draw the text into
10
+ * `body > .rp-print`; then window.print(), and print.css shows only it
10
11
  * openIssueSheet(args) the two-tap Issue: the sheet reads the draft, the PM presses Issue, the file is frozen
11
12
  * openNotesSheet(args) one sheet for every note of the draft
12
13
  * draftNow(args) POST /api/report/draft, then toast
@@ -24,6 +25,7 @@ import { ensureSprite } from './icons.js';
24
25
  import { pmGet, pmPost } from './http.js';
25
26
  import { registerTodayCard } from './registry.js';
26
27
  import { COPY } from './reports-copy.js';
28
+ import { slidesBlock, printSlides } from './report-slides.js';
27
29
 
28
30
  /* ── the markdown subset the drafter writes ───────────────────────────────────────────────────────────────────────── */
29
31
 
@@ -31,7 +33,9 @@ const NOTE_RE = /<!-- pmos:note ([a-z0-9][a-z0-9._-]*) -->\n?([\s\S]*?)\n?<!-- \
31
33
  const MARK = '\u0001NOTE:';
32
34
  const FRONT = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
33
35
 
34
- const stripFront = (md) => String(md || '').replace(FRONT, '').replace(/\r\n/g, '\n');
36
+ // the slides' data block (Run 6) is for the slides only: no reader of the text sees it
37
+ const SLIDES_DATA = /\n?<!-- pmos:slides v1 \{[^\n]*\} -->\s*$/;
38
+ const stripFront = (md) => String(md || '').replace(FRONT, '').replace(/\r\n/g, '\n').replace(SLIDES_DATA, '\n');
35
39
  const cells = (ln) => ln.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
36
40
  const isRule = (ln) => /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(ln.trim());
37
41
  const bare = (h) => String(h || '').replace(/\s*\([a-z0-9._-]+\)\s*$/, '');
@@ -40,6 +44,7 @@ const bare = (h) => String(h || '').replace(/\s*\([a-z0-9._-]+\)\s*$/, '');
40
44
  function noteTitle(id, heading) {
41
45
  if (id === 'report.headline') return COPY.noteHeadline;
42
46
  if (id === 'report.asks') return COPY.noteAsks;
47
+ if (id === 'report.changelog') return COPY.noteChangelog;
43
48
  return COPY.noteOf(heading || id);
44
49
  }
45
50
 
@@ -151,6 +156,7 @@ export function clearPrint() { for (const n of document.querySelectorAll('body >
151
156
  * of the body and shows only it. It is removed when the print dialog closes, and again before the next print.
152
157
  */
153
158
  export function printReport(md) {
159
+ if (slidesBlock(md)) { printSlides(md); return; } // a report with slides prints as slides, one per page (Run 6)
154
160
  ensureComponentsCss();
155
161
  ensurePrintCss();
156
162
  clearPrint();
@@ -61,6 +61,7 @@ export const COPY = {
61
61
  notesSub: 'A note is your text between the markers. Saving a note draws the report again from the files as they are now.',
62
62
  noteHeadline: 'Headline note',
63
63
  noteAsks: 'Note for needs your call',
64
+ noteChangelog: 'Note for the changelog slide',
64
65
  noteOf: (title) => 'Note on ' + title,
65
66
  noteSave: 'Save notes',
66
67
  noteSaved: 'Notes saved.',
@@ -103,6 +104,8 @@ export const COPY = {
103
104
  firesOnlyRunning: 'A schedule fires only while the workspace server runs.',
104
105
 
105
106
  flagTitle: 'The issues ledger has a line that is not read',
107
+
108
+ present: 'Present',
106
109
  };
107
110
 
108
111
  export const GLOSSARY = {