create-pmos 0.493.0 → 0.495.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.
Files changed (82) hide show
  1. package/package.json +99 -27
  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/briefing-premium-adddoc-test.js +2 -1
  7. package/workspace/briefing-premium-board-test.js +5 -4
  8. package/workspace/briefing-premium-initiative-test.js +4 -2
  9. package/workspace/changelog.json +98 -0
  10. package/workspace/guide/briefing-premium.md +4 -1
  11. package/workspace/guide/claude-proposes.md +2 -2
  12. package/workspace/guide/data-freshness.md +3 -1
  13. package/workspace/guide/initiatives-screen.md +3 -1
  14. package/workspace/guide/report-slides.md +14 -1
  15. package/workspace/guide/reports.md +2 -0
  16. package/workspace/guide/workflows.md +15 -3
  17. package/workspace/guide/your-impact.md +6 -7
  18. package/workspace/ini-workflow-test.js +2 -1
  19. package/workspace/lib/board-trail.js +30 -3
  20. package/workspace/lib/engine/workflows.js +19 -6
  21. package/workspace/lib/git-shape.js +21 -0
  22. package/workspace/lib/grill-runs.js +21 -6
  23. package/workspace/lib/grill.js +1 -1
  24. package/workspace/lib/impact.js +29 -16
  25. package/workspace/lib/initiative-cli.js +15 -1
  26. package/workspace/lib/open-mission.js +14 -0
  27. package/workspace/lib/page-warm.js +68 -0
  28. package/workspace/lib/report-design.js +6 -1
  29. package/workspace/lib/report-export.js +150 -9
  30. package/workspace/lib/report-pyramid.js +535 -0
  31. package/workspace/lib/report-structure.js +5 -1
  32. package/workspace/lib/report-templates.js +111 -20
  33. package/workspace/lib/routes-boards.js +36 -3
  34. package/workspace/lib/routes-brief-page.js +21 -3
  35. package/workspace/lib/routes-grill.js +10 -4
  36. package/workspace/lib/routes-impact.js +12 -4
  37. package/workspace/lib/routes-initiatives.js +1 -0
  38. package/workspace/lib/routes-programs.js +6 -4
  39. package/workspace/lib/routes-report-design.js +15 -0
  40. package/workspace/lib/routes-report-flow.js +115 -19
  41. package/workspace/lib/routes-report-setups.js +4 -2
  42. package/workspace/lib/routes-report-structure.js +3 -0
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/wf-bridge.js +5 -3
  45. package/workspace/lib/workflow-cli.js +2 -2
  46. package/workspace/lib/workflows-store.js +15 -1
  47. package/workspace/metric-drafts-ui-test.js +2 -1
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/premium-board-actions-test.js +2 -1
  51. package/workspace/premium-chat-report-tab-test.js +5 -1
  52. package/workspace/premium-vw-backlog-test.js +3 -2
  53. package/workspace/premium-vw-item-test.js +3 -2
  54. package/workspace/premium-vw-jira-test.js +3 -2
  55. package/workspace/program-panel-test.js +2 -1
  56. package/workspace/public/pm/cozy-home.css +1 -0
  57. package/workspace/public/pm/cozy-vw-rp.css +12 -0
  58. package/workspace/public/pm/fetch-share.js +38 -0
  59. package/workspace/public/pm/home-app.js +24 -4
  60. package/workspace/public/pm/home-chat.js +53 -11
  61. package/workspace/public/pm/home-impact.css +23 -13
  62. package/workspace/public/pm/home-impact.js +49 -32
  63. package/workspace/public/pm/home-ini.css +2 -0
  64. package/workspace/public/pm/ini-view.css +55 -0
  65. package/workspace/public/pm/ini-view.js +7 -4
  66. package/workspace/public/pm/premium-board.js +1 -0
  67. package/workspace/public/pm/premium-insights.js +1 -0
  68. package/workspace/public/pm/premium-report-read.js +25 -6
  69. package/workspace/public/pm/premium-report-tab.js +69 -14
  70. package/workspace/public/pm/premium-shell.js +57 -6
  71. package/workspace/public/pm/premium.css +1 -0
  72. package/workspace/public/pm/report-flow.js +93 -10
  73. package/workspace/public/pm/wf-page.css +8 -0
  74. package/workspace/public/pm/wf-page.js +33 -5
  75. package/workspace/server.js +24 -3
  76. package/workspace/skills/report-template.skill +10 -0
  77. package/workspace/split-merge-ui-test.js +3 -2
  78. package/workspace/stage-card-test.js +2 -1
  79. package/workspace/task-press-test.js +2 -1
  80. package/workspace/test-lists/page-open-speed.json +5 -0
  81. package/workspace/test-lists/report-pyramid.json +9 -0
  82. package/workspace/tk-tasks-test.js +2 -1
@@ -18,7 +18,9 @@
18
18
  * Issue a confirm with the three checks of GET /api/viewer-report/checks, Issue it and Not yet. POST /api/report/issue freezes the cycle.
19
19
  * Issued the lock line "Issued, so it stays as sent. Changes go into the next cycle." with Start the next cycle (RpStates).
20
20
  * Discard a card at the foot of the panel: "Discard the Oct 12 draft?", the cycle that stays, "This cannot be undone." (RpStates).
21
- * Slides the current slide, ‹ "Slide 1 of 5" ›, and a thumbnail of each slide.
21
+ * Slides the current slide, ‹ "Slide 1 of 5" ›, and a thumbnail of each slide. A cadence draft with no designed render shows the
22
+ * Pyramid deck (canvas RsA; GET /api/report-design/pyramid, lib/report-pyramid.js): answer first, the scorecard, the timeline,
23
+ * Needs your call and the sources. PDF prints the same slides, one to a page; the PowerPoint file draws them too.
22
24
  * Document the words as paper pages: the eyebrow, the title, "In one minute", numbered sections, tables, the sources as footnotes,
23
25
  * "Page n of N" and Download as PDF or Word (RpDoc; rp-parts.js paperModel). A designed cycle shows its designed page.
24
26
  * Live page the numbers Insights reads (GET /api/insights-premium): the goal tiles, Goals on track, Shipped and At risk, the picked goal's
@@ -54,7 +56,7 @@ import * as MP from './premium-mtg-present.js'; // the one presenter window fo
54
56
  import { presentInRecording } from './cozy-rec-bridge.js'; // while a meeting records, the recording tab presents it (rule 10)
55
57
  import { placeholderHtml } from './cozy-placeholder.js';
56
58
  import { vzStyle } from './premium-vw-open.js';
57
- import { reviewHtml, ICON as RF_ICON } from './report-flow.js'; // Claude's review and the template icon (batch 22 item 14, canvas RfH2, RfH6)
59
+ import { reviewHtml, chatOf as rfChatOf, ICON as RF_ICON } from './report-flow.js'; // Claude's review and the template icon (batch 22 item 14, canvas RfH2, RfH6)
58
60
  import { adler } from './cozy-adler.js'; // registers window.czViews; vzStyle adds the Viewer's sheet (cozy-vw.css)
59
61
  import { cycleGroups, cycleName, briefModel, briefGlanceHtml, briefConfirmHtml, briefWhyHtml, briefLooksHtml, paperModel, paperHtml, liveModel, liveHtml, presentHtml, dayMon, reportSummary,
60
62
  rpSumHtml, rpBarHtml, cadenceWords, goesOutWords, FORMATS, RP_ICONS } from './rp-parts.js';
@@ -170,6 +172,8 @@ const frame = (style, inner, cls, title) => `<iframe sandbox="" tabindex="-1" ti
170
172
  /* ── what is shown ── */
171
173
  function slides() {
172
174
  if (R.cyc && R.cyc.slides.length) return R.cyc.slides.map((x) => ({ k: 'frame', h: x.h, html: x.html, css: R.cyc.style }));
175
+ // a cadence draft with no designed render: the finished Pyramid deck (canvas RsA), in the report's saved look or the standard PMOS look
176
+ if (R.pyr && R.pyr.slides.length) return R.pyr.slides.map((x) => ({ k: 'frame', h: x.h, html: x.html, css: R.pyr.style }));
173
177
  const w = R.W || { title: R.name, sections: [] };
174
178
  return [titleSlide(w)]
175
179
  .concat(w.sections.map((x) => ({ k: 'text', h: x.h, html: slideHtml(x.lines) })));
@@ -493,6 +497,16 @@ async function rvAct(k, v) {
493
497
  say((r.started ? 'The report\'s session started with ' : 'Passed to the report\'s session: ') + (todo.length === 1 ? 'one change' : todo.length + ' changes') + '. They show here as it writes them. Nothing is issued.');
494
498
  }
495
499
  function openTemplate() { import('./premium-report-tab.js').then((M) => M.openReportTab({ kind: 'report', id: R.id }, { panel: 'template' })); }
500
+ /** The chat icon (Wawan, 2026-11-02): the report's own conversation beside it. Home opens it in its chat (pmos-chat-about); another page goes
501
+ * to Home with the report open, and the conversation's context waits in sessionStorage for Home to read. */
502
+ async function openChat() {
503
+ const me = R; if (!me || !me.id) return;
504
+ const st = await pmGet('/api/report-flow?' + q({ ws: me.ws, id: me.id })).catch(() => null);
505
+ const t = rfChatOf({ name: me.name, ...(st && st.ok !== false ? st : {}), latest: (st && st.latest) || { path: me.row.path } }, me.id);
506
+ if (!dispatchEvent(new CustomEvent('pmos-chat-about', { detail: t, cancelable: true }))) return; // Home took it
507
+ try { sessionStorage.setItem('pmos-chat-about', JSON.stringify(t)); } catch { /* Home opens the chat with no context */ }
508
+ location.href = '/premium.html?' + q({ about: t.key, label: t.title, open: innerWidth > 760 ? 'report:' + me.id : '' }); // a phone: the chat takes the page
509
+ }
496
510
  /** What Claude is still doing on this report, said where the PM reads it (Wawan, 2026-11-01, batch 22 item 13: "I have to wait, but there's
497
511
  * no indication of me having to wait"). Nothing running → ''. */
498
512
  export function waitWords(x, draft) {
@@ -910,6 +924,8 @@ function saveMd() {
910
924
  function printHtml() {
911
925
  const t = esc(R.name + ' ' + (R.row.slot || ''));
912
926
  if (R.cyc) return frameDoc(R.cyc.style, R.view === 'deck' && R.cyc.slides.length ? R.cyc.slides.map((x) => x.html).join('') : R.cyc.body, R.view === 'deck' && R.cyc.slides.length ? 'pmos-print pmos-print-deck' : 'pmos-print pmos-print-doc').replace('<head>', '<head><title>' + t + '</title>');
927
+ // PDF = the same Pyramid slides, one to a 1280 x 720 page (canvas RsA, RsD)
928
+ if (R.pyr && R.pyr.slides.length && R.view === 'deck') return frameDoc(R.pyr.style, R.pyr.slides.map((x) => x.html).join(''), 'pmos-print pmos-print-deck').replace('<head>', '<head><title>' + t + '</title>');
913
929
  const css = '<link rel="stylesheet" href="' + esc(href('premium-report-print.css')) + '">';
914
930
  const w = R.W || { title: R.name, sections: [] };
915
931
  const inner = R.view === 'deck'
@@ -930,7 +946,7 @@ function printIt() {
930
946
  * its saved design is HTML the presenter cannot read yet, so it, and a presenter that does not start, use the full-screen view below. */
931
947
  function presentIt() {
932
948
  const me = R;
933
- if (R.cyc || !R.W || R.err) return present(true);
949
+ if (R.cyc || (R.pyr && R.view === 'deck') || !R.W || R.err) return present(true);
934
950
  if (presentInRecording({ kind: R.view === 'deck' ? 'pmos-deck' : 'report', ref: R.row.path, title: R.name, ws: R.ws })) return;
935
951
  if (typeof MP.presentMaterial === 'function') {
936
952
  const win = typeof MP.openAudience === 'function' ? MP.openAudience() : null;
@@ -971,7 +987,9 @@ async function readCycle() {
971
987
  const c = await Promise.resolve(pmGet('/api/report-design/cycle?' + q({ ws: R.ws, path: R.row.path }))).catch(() => null);
972
988
  if (R !== me) return;
973
989
  R.cyc = null;
974
- if (c && c.ok && c.designed) { try { const x = splitRender(await textOf('/api/report-design/cycle-html?' + q({ ws: R.ws, path: R.row.path }))); if (R === me) R.cyc = x; } catch { /* the words are shown */ } }
990
+ if (c && c.ok && c.designed) { try { const x = splitRender(await textOf('/api/report-design/cycle-html?' + q({ ws: R.ws, path: R.row.path }))); if (R === me) { R.cyc = x; R.pyr = null; } } catch { /* the words are shown */ } }
991
+ // not designed: the Pyramid deck of a cadence draft (404 for any other report, which keeps the title slide and a slide per section)
992
+ else { let x = null; try { x = splitRender(await textOf('/api/report-design/pyramid?' + q({ ws: R.ws, path: R.row.path }))); } catch { x = null; } if (R === me) R.pyr = x && x.slides.length ? x : null; }
975
993
  }
976
994
  const viewUrl = (row, ws) => '/premium-viewer.html?' + q({ open: 'report', path: row.path, ws });
977
995
 
@@ -985,11 +1003,12 @@ export async function openReport(row, hand = {}) {
985
1003
  vzStyle(); rpStyle();
986
1004
  if (R) present(false);
987
1005
  const me = R = { row, ws: hand.ws || '', id: c.cadence, name: hand.name || row.name || c.cadence || 'Report', audience: hand.audience || '', issued: row.state === 'issued', archived: !!row.archived,
988
- text: '', err: '', W: null, view: 'deck', BF: null, bfRaw: null, bfMsg: '', LV: null, blank: false, i: 0, cyc: null, setup: undefined, pop: null, conf: false, checks: null, issueErr: '', busy: false, ask: '', askErr: '', edit: false, msg: null,
1006
+ text: '', err: '', W: null, view: 'deck', BF: null, bfRaw: null, bfMsg: '', LV: null, blank: false, i: 0, cyc: null, pyr: null, setup: undefined, pop: null, conf: false, checks: null, issueErr: '', busy: false, ask: '', askErr: '', edit: false, msg: null,
989
1007
  sch: schedPick(null), schErr: '', shr: null, rv: null, rvHide: new Set(), rvSent: new Set(), shares: null, more: [], hand, cuts: hand.cuts ? { on: 'team', team: null, sponsors: null, err: '' } : null, cycles: (hand.cycles || []).slice(), onIssue: hand.onIssue || null, onChange: hand.onChange || null, vid: 'report:' + row.path };
990
1008
  const head = me.head = () => ({ id: me.vid, kind: 'Report' + (me.audience ? ' · ' + me.audience : ''), title: me.name, icon: me.view === 'doc' ? 'doc' : 'report', full: true, url: viewUrl(row, me.ws), more: me.more,
991
1009
  // the template behind one icon (batch 22 item 14, canvas RfH1): format, readers, when it goes out, and its history
992
- presses: me.setup ? [{ act: 'template', label: 'Template: format, readers, look, sources', icon: RF_ICON.tune }] : [], onPress: (a) => { if (a === 'template' && R === me) openTemplate(); },
1010
+ presses: me.setup ? [{ act: 'chat', label: 'Talk to Claude about this report', icon: RF_ICON.chat }, { act: 'template', label: 'Template: format, readers, look, sources', icon: RF_ICON.tune }] : [],
1011
+ onPress: (a) => { if (R !== me) return; if (a === 'template') openTemplate(); else if (a === 'chat') openChat(); },
993
1012
  render: (b) => { syncMore(); b.innerHTML = panelHtml(); wire(b); }, onClose: () => { if (R === me && !me.reopen) { present(false); R = null; } } });
994
1013
  vOpen(head());
995
1014
  try { R.text = await textOf('/api/doc?' + q({ ws: R.ws, path: row.path })); R.W = wordsOf(R.text, R.name); if (!hand.name && R.W.title) R.name = R.W.title; } catch (x) { R.err = String((x && x.message) || x); R.W = { title: R.name, sections: [] }; }
@@ -23,7 +23,9 @@
23
23
  * A CHANGE Claude passes from the chat (block.change) goes to the report's own session (POST /api/report-flow/ask), then the report opens.
24
24
  * THE TEMPLATE (panel 'template', the header icon): the same questions on the current version, "changed" marks, Template history with Go back
25
25
  * (POST /api/report-flow/restore), Keep the current template and Make a different template (POST /api/report-flow/template). Its sections and
26
- * look, the cycles and Delete template stay one press further (panel 'manage', the view described above).
26
+ * look show as two read-only rows (canvas TplA): a sections change the PM named in the chat (block.sections) or a newer look made in Design
27
+ * is ONE ✦ "Make T<n+1>" (✕ drops it, ✓ makes it, with the settings, in one press). Editing the sections and look, the cycles and Delete
28
+ * template stay one press further (panel 'manage', the view described above).
27
29
  *
28
30
  * Every write is a press here, behind the launch nonce (http.js pmPost). Claude writes nothing: the routes refuse an agent session. A render is
29
31
  * HTML the server sanitized, shown in a frame with an empty sandbox, so nothing in it runs. Nothing is sent outward. */
@@ -79,16 +81,37 @@ export function openReportTab(block, o = {}) {
79
81
  if (!t && block.proposal) { setTimeout(() => adoptSetUp(id, block.proposal.name), 0); }
80
82
  if (!t || JSON.stringify(t.block) !== JSON.stringify(block) || (o.panel && o.panel !== t.panel)) {
81
83
  t = { id, block, nv: 'deck', sched: block.proposal ? (block.proposal.schedule || 'off') : null, open: false, view: 'structure', busy: false, msg: '', setup: null, docs: [], S: null, Z: null, sv: null, dv: null, render: new Map(), gone: false, loaded: false, i: 0, ask: null,
82
- q: block.proposal ? RF.picksOf(block.proposal) : null, panel: block.proposal ? 'new' : o.panel || 'current', chat: o.chat || (t && t.chat) || null, F: null, tq: null, passed: t ? t.passed : false };
84
+ q: block.proposal ? RF.picksOf(block.proposal) : null, panel: block.proposal ? 'new' : o.panel || (tplAsk(block) ? 'template' : 'current'), chat: o.chat || (t && t.chat) || null, F: null, tq: null, passed: t ? t.passed : false };
83
85
  T.set(id, t);
84
86
  if (block.id && t.panel === 'manage') load(t);
85
87
  } else if (o.chat) t.chat = o.chat;
86
88
  // a change passes on the PM's press of the chip only; an answer that opens the view by itself shows the report as it is
87
89
  if (block.id && block.change && !t.passed && !o.auto && !PASSED.has(JSON.stringify(block))) { PASSED.add(JSON.stringify(block)); t.passed = true; passChange(t); return show(t); }
88
90
  if (block.id && t.panel === 'current') return current(t);
89
- if (block.id && t.panel === 'template') { const v = show(t); loadFlow(t).then(() => redraw(t)); return v; }
91
+ if (block.id && t.panel === 'template') { const v = show(t); loadFlow(t).then(() => { redraw(t); if (!o.auto) makeLook(t); }); return v; }
90
92
  return show(t);
91
93
  }
94
+ /** A chat change to a set-up report's sections or look opens its template (canvas TplA), where it waits as one ✦. */
95
+ const tplAsk = (b) => !!(b && b.id && !b.change && ((Array.isArray(b.sections) && b.sections.length) || (typeof b.design === 'string' && b.design.trim())));
96
+ /** The look the PM asked for in words is made first (a design job, as Design's turn), once per chat change, on the PM's press of the chip; when
97
+ * it lands it is the newer look, and the ✦ offers it with the sections. Nothing is saved before ✓. */
98
+ async function makeLook(t) {
99
+ const words = typeof t.block.design === 'string' ? t.block.design.trim() : '';
100
+ const key = 'look:' + JSON.stringify(t.block);
101
+ if (!words || PASSED.has(key) || !t.F || !t.F.ok) return;
102
+ PASSED.add(key);
103
+ const had = t.F.look && t.F.look.newer ? t.F.look.newer.n : null;
104
+ const r = await post('/api/report-design/turn', { id: t.block.id, text: words, points: [] });
105
+ if (!r || !r.ok) { t.msg = 'The look was not started: ' + why(r, 'the page could not reach PMOS') + '.'; redraw(t); return; }
106
+ t.making = words; redraw(t);
107
+ const end = Date.now() + POLL_MAX;
108
+ while (Date.now() < end && T.get(t.id) === t) {
109
+ await new Promise((ok) => setTimeout(ok, POLL_MS * 2));
110
+ const F = await get('/api/report-flow', { id: t.block.id });
111
+ if (F && F.ok && F.look && F.look.newer && F.look.newer.n !== had) { t.F = Object.assign({}, t.F, { look: F.look }); t.making = null; t.tplDrop = false; redraw(t); return; }
112
+ }
113
+ t.making = null; t.msg = 'The look is not ready yet. It shows here when you open the template again.'; redraw(t);
114
+ }
92
115
  /** The report's state from GET /api/report-flow; for the template, the answers start from its current version. */
93
116
  async function loadFlow(t) {
94
117
  const F = await get('/api/report-flow', { id: t.block.id });
@@ -122,11 +145,19 @@ async function waitFor(t) {
122
145
  }
123
146
  t.waiting = false;
124
147
  }
125
- /** A change Claude passed from the chat: to the report's own session, then the report opens (RfH2). */
148
+ /** A change Claude passed from the chat: to the report's own session, on the latest draft by its file, then the report opens (RfH2).
149
+ * An issued latest cycle is frozen: the change is not passed, and the PM is told to change the template instead. */
126
150
  async function passChange(t) {
127
- const r = await post('/api/report-flow/ask', { id: t.block.id, text: t.block.change });
128
- const said = r && r.ok ? (r.started ? "The report's session started with your change. It shows in the report as it writes it. Nothing is issued." : "Passed to the report's session. It shows in the report as it writes it. Nothing is issued.")
129
- : 'The change was not passed on: ' + why(r, 'the page could not reach PMOS') + '.';
151
+ const F = await loadFlow(t);
152
+ const to = F && F.ok ? RF.changeTargetOf(F) : {};
153
+ let said;
154
+ if (to.frozen) said = `The ${to.frozen} report is issued, so it is frozen. The change was not passed on: change the template for the next cycle.`;
155
+ else {
156
+ const r = await post('/api/report-flow/ask', { id: t.block.id, text: t.block.change, ...(to.path ? { path: to.path } : {}) });
157
+ const which = to.slot ? `the ${to.slot} draft` : 'the draft';
158
+ said = r && r.ok ? (r.started ? `The report's session started with your change to ${which}. It shows in the report as it writes it. Nothing is issued.` : `Passed to the report's session for ${which}. It shows in the report as it writes it. Nothing is issued.`)
159
+ : 'The change was not passed on: ' + why(r, 'the page could not reach PMOS') + '.';
160
+ }
130
161
  t.block = { kind: 'report', id: t.block.id }; t.passed = false;
131
162
  return current(t, said);
132
163
  }
@@ -239,19 +270,38 @@ function currentHtml(t) {
239
270
  function templateHtml(t) {
240
271
  if (!t.F) return '<p class="vz-empty" role="status">Reading the template.</p>';
241
272
  if (t.F.err) return `<p class="vz-msg" data-tone="bad">${esc(t.F.err)}</p>`;
242
- const T0 = t.F.template || {};
243
- const tpl = { base: t.base, current: T0.current, versions: T0.versions || [], diff: RF.diffOf(t.tq, t.base) };
244
- return RF.questionsHtml(t.tq, { template: tpl, busy: t.busy, msg: t.msg })
273
+ return RF.questionsHtml(t.tq, { template: tplOf(t), name: t.F.name, making: t.making, busy: t.busy, msg: t.msg })
245
274
  + `<p class="vz-note">Its sections and look, and its cycles: <button type="button" class="rp-linkbtn vz-hv" data-rt="manage">Sections, look and cycles</button></p>`;
246
275
  }
247
276
  const qOf = (t) => (t.block.proposal ? t.q : t.tq);
277
+ /** The sections a list of titles becomes when saved (lib/report-structure.js planFromSections): Sources last, and one named section alone gets
278
+ * "Needs your call" after it. */
279
+ export function savedTitles(L) {
280
+ const seen = new Set(), list = (L || []).map((x) => String(x || '').replace(/\s+/g, ' ').trim()).filter((x) => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()));
281
+ if (!list.length) return [];
282
+ const si = list.findIndex((x) => /^sources?\b/i.test(x) || /\bsources?$/i.test(x));
283
+ const src = si >= 0 ? list.splice(si, 1)[0] : 'Sources';
284
+ if (list.length === 1) list.push('Needs your call');
285
+ return list.concat([src]);
286
+ }
287
+ /** The template as the panel draws it (canvas TplA): its versions, its sections and look, and the change waiting for ✓: the sections the PM
288
+ * named in the chat (block.sections) when they differ from the saved ones, and a newer look made in Design and not saved yet. */
289
+ function tplOf(t) {
290
+ const F = t.F || {}, T0 = F.template || {};
291
+ const S = F.sections || { saved: null, titles: [] }, L = F.look || null;
292
+ const named = Array.isArray(t.block.sections) && t.block.sections.length ? savedTitles(t.block.sections) : null;
293
+ const sNext = named && named.map((x) => x.toLowerCase()).join('\n') !== (S.titles || []).map((x) => String(x).toLowerCase()).join('\n') ? named : null;
294
+ return { base: t.base, current: T0.current, versions: T0.versions || [], diff: RF.diffOf(t.tq, t.base), dropped: !!t.tplDrop,
295
+ sections: { saved: S.saved, titles: S.titles || [], next: sNext },
296
+ look: L ? { saved: L.saved, words: L.words, follows: L.follows || [], next: L.newer || null } : null };
297
+ }
248
298
  /** Readers, typed: kept as typed; Draft it and Make a different template wait for a name. */
249
299
  function typed(t, e) {
250
300
  const k = e.target && e.target.dataset ? e.target.dataset.rt : '';
251
301
  const q = qOf(t); if (k !== 'aud' || !q) return;
252
302
  q.audience = e.target.value;
253
303
  const b = paneOf(t) && paneOf(t).querySelector(t.block.proposal ? '[data-rt="draftit"]' : '[data-rt="maketpl"]');
254
- if (b) b.disabled = t.busy || !q.audience.trim() || (!t.block.proposal && !RF.diffOf(q, t.base).length);
304
+ if (b) b.disabled = t.busy || !q.audience.trim() || (!t.block.proposal && !RF.templateChange(tplOf(t), q).length);
255
305
  }
256
306
  /** A pick in Goes out (how often, the day, the time). The template redraws, so its "changed" marks follow. */
257
307
  function picked(t, e) {
@@ -344,12 +394,17 @@ async function press(t, e) {
344
394
  if (a === 'draftit') { await setUp(t); return; }
345
395
  if (a === 'keep') { await current(t); return; }
346
396
  if (a === 'manage') { t.panel = 'manage'; t.loaded = false; show(t); await load(t); return; }
397
+ if (a === 'tpl-no') { t.tplDrop = true; t.msg = 'Dropped. Nothing is saved; the template stays T' + ((t.F && t.F.template && t.F.template.current) || 1) + '.'; redraw(t); return; }
347
398
  if (a === 'maketpl') {
399
+ const tp = tplOf(t), body = { id: t.block.id, settings: RF.settingsOfPicks(t.tq) };
400
+ if (!tp.dropped && tp.sections.next) body.sections = tp.sections.next;
401
+ if (!tp.dropped && tp.look && tp.look.next) body.design = tp.look.next.n;
348
402
  await act(t, async () => {
349
- const r = await post('/api/report-flow/template', { id: t.block.id, settings: RF.settingsOfPicks(t.tq) });
403
+ const r = await post('/api/report-flow/template', body);
350
404
  if (!r || !r.ok) return 'The template did not change: ' + why(r, 'the page could not reach PMOS') + '.';
405
+ if (body.sections) t.block = Object.assign({}, t.block, { sections: undefined, structure: undefined });
351
406
  if (r.state) t.F = r.state; await loadFlow(t);
352
- return 'T' + r.n + ' is the current template. The next draft follows it; the earlier drafts and cycles stay with their own.';
407
+ return 'T' + r.n + ' is the current template. The next cycle follows it; the open draft stays as it is, and the earlier cycles keep their look.';
353
408
  });
354
409
  return;
355
410
  }
@@ -358,7 +413,7 @@ async function press(t, e) {
358
413
  const r = await post('/api/report-flow/restore', { id: t.block.id, n: +v });
359
414
  if (!r || !r.ok) return 'It did not go back: ' + why(r, 'the page could not reach PMOS') + '.';
360
415
  await loadFlow(t);
361
- return 'Back to T' + r.n + '. It is the current template again. Nothing is deleted.';
416
+ return 'Back to T' + r.n + (r.restored ? ': ' + r.restored : '') + '. It is the current template again. Nothing is deleted.';
362
417
  });
363
418
  return;
364
419
  }
@@ -538,14 +538,50 @@ $('#dBody').addEventListener('click', async (e) => {
538
538
  const b = e.target.closest('[data-lead="refresh"]'); if (!b || !S.key || !S.key.startsWith('pop:')) return;
539
539
  b.disabled = true; await sendText(S.key, leadAsk(S.key));
540
540
  });
541
+ /* A sent message shows at once (batch 24 item 2: "enter the prompt is so long to appear on the chat"): its turn draws the moment the PM presses
542
+ * Enter, lighter until PMOS takes it. It leaves when the transcript holds the PM's turn (the same words, or any PM turn from that moment on),
543
+ * and after 3 minutes at most. A message that was not sent leaves at once and its words go back in the box. Keyed by row key, or 'sid:' + id. */
544
+ const PEND = { key: null, list: [], th: null }, PEND_MAX = 180000;
545
+ const pendOf = (key) => { if (PEND.key !== key) { PEND.key = key; PEND.list = []; PEND.th = null; } return PEND.list; };
546
+ function pendAdd(key, text, files) {
547
+ const conv = key.startsWith('sid:') ? PEND.sidConv : S.conv;
548
+ const p = { text, files: files || [], at: new Date().toISOString(), sent: false, n: ((conv && conv.messages) || []).filter((m) => m.role === 'user').length };
549
+ pendOf(key).push(p); return p;
550
+ }
551
+ function pendHtml(key, conv) {
552
+ const list = pendOf(key); if (!list.length) return '';
553
+ const k = (t) => splitFiles(shownUser(t)).text.replace(/\s+/g, ' ').trim();
554
+ const users = ((conv && conv.messages) || []).filter((m) => m.role === 'user');
555
+ const lastUser = users.reduce((n, m) => Math.max(n, Date.parse(m.at) || 0), 0), now = Date.now();
556
+ PEND.list = list.filter((p) => {
557
+ if (!p.sent) return true;
558
+ const t0 = Date.parse(p.at);
559
+ return now - t0 <= PEND_MAX && !(lastUser && lastUser >= t0 - 2000) && !users.slice(p.n).some((m) => k(m.text) === k(p.text));
560
+ });
561
+ return PEND.list.map((p) => `<div class="turn u pend${p.sent ? '' : ' sending'}"><div class="ub">${filesHtml(p.files)}${p.text ? userHtml(p.text) : ''}</div></div>`).join('');
562
+ }
563
+ /** Draws the waiting turns now, without a read: the thread as last read plus the new turn, and the stream scrolled to its end. */
564
+ function pendDraw(key) {
565
+ if (key.startsWith('sid:')) { if (S.sid !== key.slice(4)) return; }
566
+ else if (S.key !== key) return;
567
+ const box = $('#dBody .stream'); if (!box) return;
568
+ if (!key.startsWith('sid:') && PEND.th) drawThread(PEND.th, key, S.conv);
569
+ else {
570
+ let w = $('#pendW', box); if (!w) { box.insertAdjacentHTML('beforeend', '<div id="pendW"></div>'); w = $('#pendW', box); }
571
+ putHtml(w, pendHtml(key, key.startsWith('sid:') ? PEND.sidConv : S.conv));
572
+ }
573
+ const b = $('#dBody'); b.scrollTop = b.scrollHeight;
574
+ }
541
575
  export function drawThread(th, key, conv) {
542
576
  if (S.key !== key) return;
577
+ pendOf(key); PEND.th = th;
543
578
  const box = $('#dBody .stream'); if (!box) return;
544
579
  drawLead(box, th);
545
580
  let t = $('#thr', box); if (!t) { box.insertAdjacentHTML('beforeend', '<div id="thr"></div>'); t = $('#thr', box); }
546
581
  const b = $('#dBody'), atEnd = b.scrollHeight - b.scrollTop - b.clientHeight < 80;
547
582
  S.conv = conv;
548
- putHtml(t, threadHtml(th) + sessionHtml(conv, th.goal, { answer: true }));
583
+ const pw = $('#pendW', box); if (pw) pw.remove(); // a first message drew its turn before the thread was read
584
+ putHtml(t, threadHtml(th) + sessionHtml(conv, th.goal, { answer: true }) + pendHtml(key, conv));
549
585
  ctxAuto(key, conv);
550
586
  S.goal = th.goal || null; S.draft = readyDraft(conv); S.session = th.session || null;
551
587
  syncTerm(termTarget({ live: th.session, command: th.resume, ws: new URLSearchParams(location.search).get('ws') }));
@@ -773,15 +809,29 @@ $('#dBody').addEventListener('submit', async (e) => {
773
809
  const inp = $('#askIn'), text = inp.value.trim(), key = S.key;
774
810
  if (!key && S.sid) { // premium-session-list: a session the list opened that has no row of its own
775
811
  if (!text) { $('#askNote').textContent = 'Write a message for Claude.'; return; }
776
- const id = S.sid, r = await pmPost('/api/session/say', { id, text });
777
- if (!r || !r.ok) { $('#askNote').textContent = (r && r.error) || 'The message was not sent.'; return; }
778
- inp.value = ''; $('#askNote').textContent = 'Sent.'; drawSlash(); setTimeout(() => loadSid(id), 400); return;
812
+ const id = S.sid, pk = 'sid:' + id, p = pendAdd(pk, text);
813
+ inp.value = ''; drawSlash(); growAsk(); pendDraw(pk); // the turn shows at once (batch 24 item 2)
814
+ const r = await pmPost('/api/session/say', { id, text }).catch(() => null);
815
+ if (!r || !r.ok) {
816
+ PEND.list = pendOf(pk).filter((x) => x !== p); pendDraw(pk);
817
+ if (S.sid === id && !inp.value) { inp.value = text; growAsk(); }
818
+ $('#askNote').textContent = (r && r.error) || 'The message was not sent.'; return;
819
+ }
820
+ p.sent = true; pendDraw(pk);
821
+ $('#askNote').textContent = 'Sent.'; setTimeout(() => loadSid(id), 400); return;
779
822
  }
780
823
  if (!key) { $('#askNote').textContent = 'This row has no conversation, so the message was not sent.'; return; }
781
824
  const files = attachNames();
782
825
  if (files === null) { $('#askNote').textContent = 'A file is still saving. Send when its chip shows its size.'; return; }
783
826
  if (!text && !files.length) { $('#askNote').textContent = 'Write a message for Claude.'; return; }
784
- if (await sendText(key, text, files)) { inp.value = ''; inp.placeholder = 'Message Claude, or type /'; clearAttach(); drawSlash(); growAsk(); }
827
+ // the turn shows and the box empties at once (batch 24 item 2); a send that fails takes the turn away and puts the words back
828
+ const p = pendAdd(key, text, files);
829
+ inp.value = ''; drawSlash(); growAsk(); pendDraw(key);
830
+ if (await sendText(key, text, files)) { p.sent = true; inp.placeholder = 'Message Claude, or type /'; clearAttach(); pendDraw(key); }
831
+ else {
832
+ PEND.list = pendOf(key).filter((x) => x !== p); pendDraw(key);
833
+ if (S.key === key && !inp.value) { inp.value = text; growAsk(); }
834
+ }
785
835
  });
786
836
 
787
837
  /* ── on scroll: a card's head sticks while its card is on screen. The old hero no longer folds into a dock (premium-agreed-design). ── */
@@ -1424,7 +1474,8 @@ async function loadSid(id) {
1424
1474
  syncTerm(termTarget({ live: c && c.ok && c.state !== 'ended' ? id : null }));
1425
1475
  const box = $('#dBody .stream'); if (!box) return;
1426
1476
  const b = $('#dBody'), atEnd = b.scrollHeight - b.scrollTop - b.clientHeight < 80;
1427
- putHtml(box, c && c.ok ? sessionHtml(c) : turnA(ptx('The conversation of this session could not be read.')));
1477
+ if (c && c.ok) PEND.sidConv = c;
1478
+ putHtml(box, c && c.ok ? sessionHtml(c) + pendHtml('sid:' + id, c) : turnA(ptx('The conversation of this session could not be read.')));
1428
1479
  $('#askNote').textContent = c && c.ask ? 'Claude is waiting for your answer above.' : 'Your message goes to this session.';
1429
1480
  if (atEnd) b.scrollTop = b.scrollHeight;
1430
1481
  if (c && c.ok && c.state !== 'ended' && $('#dlg').classList.contains('on')) R.sidTimer = setTimeout(() => loadSid(id), 2000);
@@ -285,6 +285,7 @@ a.round{color:inherit;text-decoration:none}
285
285
  .tc{min-width:0}
286
286
  .tt{margin:0;font-size:var(--fs-3);line-height:1.55;color:var(--ink);max-width:58ch}
287
287
  .turn.u{justify-items:end}
288
+ .turn.u.pend.sending .ub{opacity:.72} /* batch 24 item 2: a sent turn shows at once, a touch lighter until PMOS takes it */
288
289
  .ub{background:var(--solid);color:var(--on-solid);padding:11px 16px;border-radius:18px 18px 8px 18px;font-size:var(--fs-3);line-height:1.45;max-width:86%}
289
290
  .acard{margin-left:40px;background:var(--well);border-radius:var(--r-in);padding:20px 22px;display:grid;gap:14px;animation:rise .6s var(--ease) both}
290
291
  .still .turn,.still .acard{animation:none} /* a redraw of a live chat keeps its turns still (premium-shell.js putHtml) */
@@ -7,9 +7,14 @@
7
7
  * changedOf(q, base) → ['output' | 'audience' | 'schedule'] what differs from the current template
8
8
  * diffOf(q, base) → ['Format: Slides → Document', …] the same, in words
9
9
  * questionsHtml(q, o) the questions (RfH4, RfH7): Format, Readers, Goes out, Look, Sources, then Draft it.
10
- * o.template draws them as the template (RfH3): "changed" marks, the history, Keep the current template and
10
+ * o.template draws them as the template (RfH3, TplA): Format, Readers, Goes out, Sections (chips, in
11
+ * order, read only) and Look (a small title slide and one line); "changed" marks; ONE ✦ "Make T<n+1>" when
12
+ * the sections or the look change (✕ drops it, ✓ makes it); the history; Keep the current template and
11
13
  * Make a different template
12
- * historyHtml(versions) Template history: T<n>, what it is, when, the cycles issued with it, Go back to T<n>
14
+ * templateChange(T, q) → [words] everything a press of ✓ would change: the settings, the sections, the look
15
+ * changeTargetOf(F) → { path, slot } | { frozen: slot } | {} where a change from the chat goes: the latest draft; an issued
16
+ * latest cycle is frozen; no cycle yet → {} (the first draft takes it)
17
+ * historyHtml(versions) Template history: T<n>, what it is in words, when, the cycles issued with it, what Go back to T<n> restores
13
18
  * waitHtml(F) the report's current state while no cycle is written yet: drafting, ended, or no draft (RfH1)
14
19
  * reviewHtml(rv, o) Claude's review of a draft in the proposal pattern (RfH2): one line, a ✦ row per fix with Not now and Do it,
15
20
  * Do all, and "N other checks found nothing"
@@ -19,7 +24,24 @@
19
24
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
20
25
  const SV = (d, n = 16) => `<svg viewBox="0 0 24 24" width="${n}" height="${n}" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
21
26
  export const ICON = { go: 'M5 12h14M13 6l6 6-6 6', x: 'M6 6l12 12M18 6L6 18', check: 'M5 12.5l4.5 4.5L19 7.5', back: 'M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3',
22
- tune: 'M4 7h10M18 7h2M4 17h2M10 17h10M14 5v4M8 15v4' };
27
+ tune: 'M4 7h10M18 7h2M4 17h2M10 17h10M14 5v4M8 15v4', chat: 'M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4 3.5V16h.5A2.5 2.5 0 0 1 5 13.5z' };
28
+
29
+ /** The chat a report's header icon opens beside it (Wawan, 2026-11-02: "How do I chat to continue adjusting the template?"): the report's own
30
+ * conversation, pop:report-<id>. A pop: key carries what it is about as its opening context (lib/briefing-premium-agent.js popRow), so Claude knows
31
+ * the report and its draft, and a change comes back as the change block the chip passes to the report's own session. */
32
+ export function chatOf(st = {}, id = '') {
33
+ const sid = String(id).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 90) || 'report';
34
+ const name = String(st.name || 'this report'), draft = st.latest && st.latest.path ? String(st.latest.path) : '';
35
+ const settings = [st.output && (FORMAT_NAMES[st.output] || [st.output])[0], st.audience].filter(Boolean).join(' · ');
36
+ return { key: 'pop:report-' + sid, title: name,
37
+ intro: 'Tell me what to change in ' + name + '. I pass it to the report\'s own session, and the draft beside us changes. Nothing is issued.',
38
+ placeholder: 'Change the report',
39
+ context: { title: name, subject: 'A report set up in PMOS (report id ' + id + ')' + (settings ? ': ' + settings : ''),
40
+ say: 'When the PM asks to change the draft, answer in a few lines and end with ONE fenced code block with the language tag pmos-report holding {"kind":"report","id":"' + id
41
+ + '","change":"the change, in the PM\'s words"}. The PM presses its chip to pass it to the report\'s own session. Do not change the draft yourself. The format, readers, schedule, look and sources change with the template icon in the report\'s header.',
42
+ facts: [['Report id', id]].concat(draft ? [['Latest draft', draft]] : []), files: draft ? [draft] : [] } };
43
+ }
44
+
23
45
  export const FORMAT_NAMES = { 'google-slides': ['Slides', 'Read aloud or presented.'], 'google-doc': ['Document', 'Read alone, with tables and footnotes.'],
24
46
  pdf: ['PDF', 'A fixed page to read or print.'], 'google-sheets': ['Sheets', 'The numbers as a table.'], confluence: ['Confluence page', 'A page in your wiki.'] };
25
47
  export const CADENCES = [['weekly', 'Every week'], ['bi-weekly', 'Every two weeks'], ['monthly', 'Every month'], ['daily', 'Every day']];
@@ -27,6 +49,11 @@ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
27
49
  const TIMES = ['07:00', '08:00', '09:00', '10:00', '12:00', '14:00', '16:00', '17:00'];
28
50
 
29
51
  /** The answers a new report starts with: Claude's picks. */
52
+ export function changeTargetOf(F) {
53
+ const L = F && F.latest;
54
+ if (!L || !L.path) return {};
55
+ return L.state === 'issued' ? { frozen: L.slot } : { path: L.path, slot: L.slot };
56
+ }
30
57
  export function picksOf(p = {}) {
31
58
  const s = p.schedule && typeof p.schedule === 'object' && p.schedule.cadence ? p.schedule : null;
32
59
  return { output: p.output || 'google-slides', audience: p.audience || '', repeat: !!s, cadence: s ? s.cadence : 'weekly', day: (s && s.day) || 'Mon', dayOfMonth: (s && s.dayOfMonth) || 1, time: (s && s.time) || '09:00',
@@ -100,6 +127,55 @@ function lookHtml(o) {
100
127
  const words = o.look ? `<p class="vz-note">Your words: “${esc(o.look)}”. Claude makes this look after the first draft; until then it uses the standard look.</p>` : '';
101
128
  return `<fieldset class="rf-q"><legend>Look</legend><p class="rf-fix"><b>Standard PMOS look</b><span>Nunito, navy title slides, 16:9</span></p>${words}</fieldset>`;
102
129
  }
130
+ /* ── the template's sections and look (canvas TplA) ── */
131
+ /** Section chips in order. was: the saved titles; a title new in `now` is marked new, a title gone is struck through. */
132
+ function chipsHtml(now, was) {
133
+ const low = (L) => (L || []).map((x) => String(x).toLowerCase());
134
+ const w = low(was), n = low(now);
135
+ const on = (now || []).map((t, i) => `<span class="rf-cp"${was && !w.includes(n[i]) ? ' data-k="new"' : ''}>${esc(t)}${was && !w.includes(n[i]) ? '<span class="rf-sr"> (new)</span>' : ''}</span>`);
136
+ const gone = was ? (was || []).filter((t, i) => !n.includes(w[i])).map((t) => `<span class="rf-cp" data-k="gone"><s>${esc(t)}</s><span class="rf-sr"> (taken out)</span></span>`) : [];
137
+ return `<div class="rf-cps">${on.concat(gone).join('')}</div>`;
138
+ }
139
+ function tplSectionsHtml(T) {
140
+ const S = T.sections; if (!S) return '';
141
+ const next = S.next && !T.dropped ? S.next : null;
142
+ return `<fieldset class="rf-q"><legend>Sections${next ? ' ' + tag('changed', 'ch') : ''}</legend>${chipsHtml(next || S.titles || [], next ? S.titles || [] : null)}</fieldset>`;
143
+ }
144
+ function tplLookHtml(T, name, o) {
145
+ const L = T.look; if (!L) return '';
146
+ const next = L.next && !T.dropped ? L.next : null;
147
+ const words = next ? next.words : L.words;
148
+ const std = next ? next.n == null : L.saved == null;
149
+ const fol = !std && (L.follows || []).length ? `<small>Follows ${esc(L.follows.join(', '))}</small>` : std ? '<small>Nunito, navy title slides, 16:9</small>' : '';
150
+ const making = o && o.making ? `<p class="vz-note" role="status">Claude is making the look you asked for: “${esc(o.making)}”. It shows here to approve when it is ready.</p>` : '';
151
+ return `<fieldset class="rf-q"><legend>Look${next ? ' ' + tag('changed', 'ch') : ''}</legend><div class="rf-lk"><span class="rf-mini" data-k="${std ? 'std' : 'own'}" aria-hidden="true"><b>${esc(name || '')}</b></span>`
152
+ + `<p class="rf-fix"><b>${esc(words || 'Standard PMOS look')}</b>${fol}</p></div>${making}</fieldset>`;
153
+ }
154
+ /** Everything ✓ would change, in words: the settings (diff), then the sections and the look. */
155
+ export function templateChange(T, q) {
156
+ if (!T) return [];
157
+ const out = (q ? changedOf(q, T.base).length : 0) ? (T.diff || []).slice() : [];
158
+ if (T.dropped) return out;
159
+ if (T.sections && T.sections.next) out.push('Sections: ' + (sectionsDiff(T.sections.titles, T.sections.next) || 'the same'));
160
+ if (T.look && T.look.next) out.push('Look: ' + (T.look.words || 'Standard PMOS look') + ' → ' + (T.look.next.words || 'Standard PMOS look'));
161
+ return out;
162
+ }
163
+ /** "+ Customers, − Risk watch", or "new order" (the same words lib/report-templates.js sectionsDiff says). */
164
+ export function sectionsDiff(was, now) {
165
+ const A = was || [], B = now || [], a = A.map((x) => String(x).toLowerCase()), b = B.map((x) => String(x).toLowerCase());
166
+ const out = B.filter((t, i) => !a.includes(b[i])).map((t) => '+ ' + t).concat(A.filter((t, i) => !b.includes(a[i])).map((t) => '− ' + t));
167
+ return out.length ? out.join(', ') : a.join('\n') !== b.join('\n') ? 'new order' : '';
168
+ }
169
+ /** The ✦ that makes T<n+1> (TplA 2, 3): the change in words, "T<n> keeps its draft and cycles", ✕ drops it, ✓ makes it. Only for a sections
170
+ * or look change: a format, readers or schedule change the PM made here is said in the note under the questions, as before. */
171
+ function proposalHtml(T, words, a, o) {
172
+ if (T.dropped || !((T.sections && T.sections.next) || (T.look && T.look.next))) return '';
173
+ const n = (T.versions || []).reduce((m, v) => Math.max(m, v.n), 0) + 1;
174
+ return `<div class="rf-fx rf-tplx"><span class="rf-star" aria-hidden="true">✦</span><span class="rf-fxt"><b>Make T${n}</b>${words.map((w) => `<span>${esc(w)}</span>`).join('')}`
175
+ + `<span>T${esc(T.current || 1)} keeps its draft and cycles.</span></span>`
176
+ + `<button type="button" class="rf-ib vz-hv" ${a}="tpl-no" aria-label="Not now" title="Not now">${SV(ICON.x)}</button>`
177
+ + `<button type="button" class="rf-ib pri vz-hv" ${a}="maketpl" aria-label="Make T${n}" title="Make T${n}"${o.busy ? ' disabled' : ''}>${SV(ICON.check)}</button></div>`;
178
+ }
103
179
  function sourcesHtml(o) {
104
180
  const L = (o.sources || []).filter(Boolean);
105
181
  if (!L.length) return '';
@@ -110,9 +186,11 @@ export function historyHtml(versions = [], a = 'data-rt', o = {}) {
110
186
  if (!versions.length) return '';
111
187
  const day = (iso) => { const d = new Date(iso || ''); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : ''; };
112
188
  const pend = o.pending ? `<li class="rf-hv" data-k="new"><b>T${(versions[0] ? versions[0].n : 0) + 1}</b><span>${esc(o.pending)}<em>Unsaved · waits for Make a different template</em></span></li>` : '';
113
- const rows = versions.map((v) => `<li class="rf-hv"${v.current ? ' aria-current="true"' : ''}><b>T${v.n}</b><span>${esc(v.why || v.words || '')}<em>${esc([day(v.at), v.current ? 'current' : '', v.cycles ? v.cycles + (v.cycles === 1 ? ' cycle issued' : ' cycles issued') : ''].filter(Boolean).join(' · '))}</em></span>`
189
+ // each version says what it is; one that is not current also says what Go back restores (TplA 4): format, readers, schedule, sections, look
190
+ const rows = versions.map((v) => `<li class="rf-hv"${v.current ? ' aria-current="true"' : ''}><b>T${v.n}</b><span>${esc(v.why || v.words || '')}<em>${esc([day(v.at), v.current ? 'current' : '', v.cycles ? v.cycles + (v.cycles === 1 ? ' cycle issued' : ' cycles issued') : ''].filter(Boolean).join(' · '))}</em>`
191
+ + (!v.current && v.words ? `<em class="rf-rest">Going back restores: ${esc(v.words)}</em>` : '') + '</span>'
114
192
  + (v.current ? '' : `<button type="button" class="rf-ib vz-hv" ${a}="restore|${v.n}" aria-label="Go back to T${v.n}" title="Go back to T${v.n}"${o.busy ? ' disabled' : ''}>${SV(ICON.back)}</button>`) + '</li>').join('');
115
- return `<section class="rf-hist"><h3>Template history</h3><ol>${pend}${rows}</ol><p class="vz-note">Going back makes that version the current one. Nothing is deleted.</p></section>`;
193
+ return `<section class="rf-hist"><h3>Template history</h3><ol>${pend}${rows}</ol><p class="vz-note">Going back makes that version the current one. Nothing is deleted. Cycles already made keep the look they were made with; the open draft stays as it is.</p></section>`;
116
194
  }
117
195
  /**
118
196
  * The questions. q = picksOf(…) as the PM changed it. o = { template?: { base, versions, diff }, look, sources, busy, msg }.
@@ -120,16 +198,21 @@ export function historyHtml(versions = [], a = 'data-rt', o = {}) {
120
198
  */
121
199
  export function questionsHtml(q, o = {}, a = 'data-rt') {
122
200
  const T = o.template, ch = T ? changedOf(q, T.base) : [];
123
- const st = T ? (ch.length ? 'Template · changed, not saved yet' : 'Template · T' + (T.current || 1)) : 'Waits on your answers';
201
+ const all = T ? templateChange(T, q) : [];
202
+ const nextN = T ? (T.versions || []).reduce((m, v) => Math.max(m, v.n), 0) + 1 : 0;
203
+ const st = T ? (all.length ? (ch.length ? 'Template · changed, not saved yet' : 'Template · T' + (T.current || 1) + ', T' + nextN + ' waiting') : 'Template · T' + (T.current || 1)) : 'Waits on your answers';
124
204
  const head = `<p class="rf-st"><i class="rf-dot" data-k="you" aria-hidden="true"></i>${esc(st)}</p>`;
125
- const body = formatHtml(q, ch, a) + readersHtml(q, ch, a, o) + goesHtml(q, ch, a) + lookHtml(o) + (T ? '' : sourcesHtml(o));
205
+ const tpl = T && (T.sections || T.look);
206
+ const body = formatHtml(q, ch, a) + readersHtml(q, ch, a, o) + goesHtml(q, ch, a) + (tpl ? tplSectionsHtml(T) + tplLookHtml(T, o.name, o) : lookHtml(o)) + (T ? '' : sourcesHtml(o));
126
207
  const msg = o.msg ? `<p class="vz-msg" role="status">${esc(o.msg)}</p>` : '';
127
208
  if (!T) {
128
209
  return `<div class="rf-qs">${head}${body}${msg}<div class="rf-foot"><span class="vz-note">Nothing goes out</span><button type="button" class="rf-ib pri vz-hv" ${a}="draftit" aria-label="Draft it" title="Draft it"${o.busy || !q.audience.trim() ? ' disabled' : ''}>${SV(ICON.go, 18)}</button></div></div>`;
129
210
  }
130
- const words = ch.length ? T.diff : [];
131
- const note = words.length ? `<p class="rf-chg">${tag('changed', 'ch')} ${esc(words.join('; '))}. This makes a different template. The current one keeps its draft and its cycles.</p>` : '';
132
- return `<div class="rf-qs">${head}${body}${note}${historyHtml(T.versions, a, { pending: words.length ? words.join('; ') : '', busy: o.busy })}${msg}`
211
+ const words = all;
212
+ const fx = proposalHtml(T, words, a, o);
213
+ const note = words.length && !fx ? `<p class="rf-chg">${tag('changed', 'ch')} ${esc(words.join('; '))}. This makes a different template. The current one keeps its draft and its cycles.</p>` : '';
214
+ const ask = tpl && !fx ? `<p class="vz-note">To change the look or the sections, ask in the chat. Claude proposes; you approve.</p>` : '';
215
+ return `<div class="rf-qs">${head}${body}${fx}${note}${ask}${historyHtml(T.versions, a, { pending: words.length ? words.join('; ') : '', busy: o.busy })}${msg}`
133
216
  + `<div class="rf-foot"><button type="button" class="rf-ib vz-hv" ${a}="keep" aria-label="Keep the current template" title="Keep the current template">${SV(ICON.back)}</button>`
134
217
  + `<button type="button" class="rf-ib pri vz-hv" ${a}="maketpl" aria-label="Make a different template" title="Make a different template"${o.busy || !words.length || !q.audience.trim() ? ' disabled' : ''}>${SV(ICON.check, 18)}</button></div></div>`;
135
218
  }
@@ -23,6 +23,14 @@ body.wf-page { min-height: 100vh; overflow: hidden; font-family: var(--cz-sans);
23
23
  .wfl .t { font-size: var(--fs-3); font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
24
  .wfl .n { font-size: var(--fs-2); color: var(--cz-ink3); font-weight: 600; }
25
25
  .wfl.arch .t { color: var(--cz-ink3); }
26
+ .wfl .wfb { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; text-align: left; }
27
+ .wfl .wfb:focus-visible, .wfl .wfs:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 8px; }
28
+ /* the star (batch 24 item 3, canvas DfA): filled on the default; an outline only on hover or focus elsewhere, and always on touch */
29
+ .wfl .wfs { width: 30px; height: 30px; margin: -4px 0; border-radius: 999px; display: inline-grid; place-items: center; border: 0; padding: 0; background: none; color: var(--cz-ink3); cursor: pointer; flex: none; opacity: 0; }
30
+ .wfl:hover .wfs, .wfl:focus-within .wfs, .wfl .wfs.on { opacity: 1; }
31
+ .wfl .wfs:hover { background: var(--cz-card); color: var(--cz-ink2); box-shadow: 0 0 0 1px var(--cz-hair); }
32
+ .wfl .wfs.on, .wfl .wfs.on:hover { color: var(--cz-risk); }
33
+ @media (hover: none) { .wfl .wfs { opacity: 1; } }
26
34
  .wf-sub { font-size: var(--fs-1); font-weight: 800; color: var(--cz-ink3); padding: 14px 10px 4px; }
27
35
  .wf-page .wfi { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 6px; background: var(--cz-well); color: var(--cz-ink2); flex: none; }
28
36
  .wfl .wfi, .wf-head .wfi { background: var(--cz-well2); }