create-pmos 0.123.0 → 0.125.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 (127) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/nine-stages.js +81 -0
  6. package/payload/scripts/workflow-state.js +2 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/c3-one-home-test.js +131 -0
  10. package/workspace/fixtures/initiatives-api/list.json +171 -3
  11. package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
  12. package/workspace/fixtures/initiatives-api/sheet.json +57 -1
  13. package/workspace/fixtures/one-home-baseline.json +83 -0
  14. package/workspace/guide/layout.md +2 -0
  15. package/workspace/guide/notes.md +1 -0
  16. package/workspace/lib/engine/nine-stages.js +81 -0
  17. package/workspace/lib/engine/workflow-state.js +2 -0
  18. package/workspace/lib/facts-async.js +51 -0
  19. package/workspace/lib/item-steps.js +52 -0
  20. package/workspace/lib/jira-status.js +8 -3
  21. package/workspace/lib/jobs-to-be-done.js +52 -0
  22. package/workspace/lib/outbox-store.js +87 -0
  23. package/workspace/lib/pm-items.js +115 -0
  24. package/workspace/lib/route-modules.js +21 -0
  25. package/workspace/lib/routes-briefing.js +100 -0
  26. package/workspace/lib/routes-c3-init.js +53 -0
  27. package/workspace/lib/routes-c3-life.js +119 -0
  28. package/workspace/lib/routes-c3-strategy.js +169 -0
  29. package/workspace/lib/routes-c3-timeline.js +191 -0
  30. package/workspace/lib/routes-calendar-import.js +60 -0
  31. package/workspace/lib/routes-calendar-views.js +61 -0
  32. package/workspace/lib/routes-doc-health.js +79 -0
  33. package/workspace/lib/routes-doc-sheet.js +169 -0
  34. package/workspace/lib/routes-forecast.js +85 -0
  35. package/workspace/lib/routes-hooks.js +121 -0
  36. package/workspace/lib/routes-initiatives.js +3 -0
  37. package/workspace/lib/routes-intake.js +24 -0
  38. package/workspace/lib/routes-outbox.js +97 -0
  39. package/workspace/lib/routes-pm-items.js +216 -0
  40. package/workspace/lib/routes-records.js +61 -0
  41. package/workspace/lib/routes-releases.js +129 -0
  42. package/workspace/lib/routes-report-facts.js +84 -0
  43. package/workspace/lib/routes-report-families.js +32 -0
  44. package/workspace/lib/routes-roadmap-ops.js +141 -0
  45. package/workspace/lib/routes-runs.js +119 -0
  46. package/workspace/lib/routes-signals.js +50 -0
  47. package/workspace/lib/run-answers.js +69 -0
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/public/c3.html +26 -0
  51. package/workspace/public/pm/briefing-deck.js +415 -0
  52. package/workspace/public/pm/briefing-model.js +176 -0
  53. package/workspace/public/pm/c3-breakdown.js +240 -0
  54. package/workspace/public/pm/c3-brief.js +278 -0
  55. package/workspace/public/pm/c3-cal-strategy.js +528 -0
  56. package/workspace/public/pm/c3-case.js +173 -0
  57. package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
  58. package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
  59. package/workspace/public/pm/c3-demo-cal2.js +66 -0
  60. package/workspace/public/pm/c3-demo-cal3.js +173 -0
  61. package/workspace/public/pm/c3-demo-data.js +560 -0
  62. package/workspace/public/pm/c3-demo-init.js +373 -0
  63. package/workspace/public/pm/c3-demo-life.js +77 -0
  64. package/workspace/public/pm/c3-demo-misc2.js +46 -0
  65. package/workspace/public/pm/c3-demo-plan2.js +30 -0
  66. package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
  67. package/workspace/public/pm/c3-demo-rep2.js +92 -0
  68. package/workspace/public/pm/c3-demo-term.js +153 -0
  69. package/workspace/public/pm/c3-demo-time.js +49 -0
  70. package/workspace/public/pm/c3-demo-work-ix.js +80 -0
  71. package/workspace/public/pm/c3-demo-work2.js +13 -0
  72. package/workspace/public/pm/c3-demo.js +113 -0
  73. package/workspace/public/pm/c3-doc-sheet.js +314 -0
  74. package/workspace/public/pm/c3-dock.js +180 -0
  75. package/workspace/public/pm/c3-docs.js +290 -0
  76. package/workspace/public/pm/c3-expand.js +96 -0
  77. package/workspace/public/pm/c3-init-extras.js +170 -0
  78. package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
  79. package/workspace/public/pm/c3-misc-panels.js +89 -0
  80. package/workspace/public/pm/c3-plan-panels.js +163 -0
  81. package/workspace/public/pm/c3-plan.js +192 -0
  82. package/workspace/public/pm/c3-roadmap.js +249 -0
  83. package/workspace/public/pm/c3-sheet.js +107 -0
  84. package/workspace/public/pm/c3-shell.js +637 -0
  85. package/workspace/public/pm/c3-signals.js +196 -0
  86. package/workspace/public/pm/c3-strategy-panels.js +45 -0
  87. package/workspace/public/pm/c3-swipe.js +138 -0
  88. package/workspace/public/pm/c3-terminal-data.js +26 -0
  89. package/workspace/public/pm/c3-terminal-edge.js +91 -0
  90. package/workspace/public/pm/c3-terminal-icons.js +41 -0
  91. package/workspace/public/pm/c3-terminal-results.js +141 -0
  92. package/workspace/public/pm/c3-terminal.js +52 -0
  93. package/workspace/public/pm/c3-tickets.js +163 -0
  94. package/workspace/public/pm/c3-timeline.js +201 -0
  95. package/workspace/public/pm/c3-toast.js +16 -0
  96. package/workspace/public/pm/calendar-views.js +1015 -0
  97. package/workspace/public/pm/console3-lift.md +220 -0
  98. package/workspace/public/pm/console3.css +1480 -0
  99. package/workspace/public/pm/dest-initiatives.js +16 -3
  100. package/workspace/public/pm/dest-mywork.js +8 -1
  101. package/workspace/public/pm/dest-notes.js +25 -3
  102. package/workspace/public/pm/dest-reports.js +47 -1
  103. package/workspace/public/pm/dest-today.js +55 -1
  104. package/workspace/public/pm/doc-health-panel.js +110 -0
  105. package/workspace/public/pm/doc-rail.js +9 -3
  106. package/workspace/public/pm/lifecycle-lens-model.js +228 -0
  107. package/workspace/public/pm/lifecycle-lens.js +261 -0
  108. package/workspace/public/pm/new-initiative-sheet.js +116 -21
  109. package/workspace/public/pm/outbox-panel.js +125 -0
  110. package/workspace/public/pm/pm-work-view.js +919 -0
  111. package/workspace/public/pm/releases-view.js +248 -0
  112. package/workspace/public/pm/report-families-view.js +503 -0
  113. package/workspace/public/pm/run-card.js +244 -0
  114. package/workspace/public/pm/sessions-panel.js +149 -0
  115. package/workspace/public/pm/shell-bell.js +162 -0
  116. package/workspace/public/pm/shell-model.js +83 -0
  117. package/workspace/public/pm/shell-phone.js +124 -0
  118. package/workspace/public/pm/shell-readaloud.js +53 -0
  119. package/workspace/public/pm/shell-recording.js +110 -0
  120. package/workspace/public/pm/shell-ticker.js +90 -0
  121. package/workspace/public/pm/slide-confirm.js +163 -0
  122. package/workspace/public/pm/timeline-forecast-view.js +164 -0
  123. package/workspace/public/pm/today-day.js +8 -2
  124. package/workspace/public/pm/tokens.css +1 -0
  125. package/workspace/server.js +5 -2
  126. package/workspace/test-files.json +8 -0
  127. package/workspace/test-lists/browser-suites-green.json +5 -0
@@ -25,6 +25,9 @@ import { openSheet } from './initiative-sheet.js';
25
25
  import { openNewInitiative } from './new-initiative-sheet.js';
26
26
  import './cycle-review.js';
27
27
  import './triage-card.js';
28
+ import './releases-view.js';
29
+ import './lifecycle-lens.js';
30
+ import './timeline-forecast-view.js';
28
31
 
29
32
  setInitiativeOpener(openSheet);
30
33
 
@@ -93,21 +96,31 @@ function draw(host, r, ctx, repo, ws) {
93
96
  if (r.extra && r.extra.dispose) { try { r.extra.dispose(r.body); } catch { /* the view is gone */ } }
94
97
  r.handle = null; r.extra = null;
95
98
 
99
+ // The Roadmap's control budget is fixed (first-paint-initiatives: PRD Q5, 24 plus the top bar), so only Timeline keeps its own
100
+ // tab; every other registered view (Releases, Lifecycle, Forecast, ...) sits behind one 'More' tab with a second row.
101
+ // Only the console3 views are grouped; any other view a later run registers keeps its own tab (the View-control contract).
102
+ const MORE_IDS = ['releases', 'lifecycle', 'forecast'];
103
+ const extras = initiativesViews().filter((v) => !BUILT_IN.includes(v.id)).map((v) => ({ id: v.id, label: v.label || v.id }));
104
+ const ownTab = extras.filter((v) => !MORE_IDS.includes(v.id));
105
+ const moreViews = extras.filter((v) => MORE_IDS.includes(v.id));
96
106
  const views = [
97
107
  { id: 'roadmap', label: COPY.roadmap },
98
108
  { id: 'list', label: COPY.list },
99
- ...initiativesViews().filter((v) => !BUILT_IN.includes(v.id)).map((v) => ({ id: v.id, label: v.label || v.id })),
109
+ ...ownTab,
110
+ ...(moreViews.length ? [{ id: 'more', label: 'More' }] : []),
100
111
  ];
101
- if (!views.some((v) => v.id === r.view)) r.view = 'roadmap';
112
+ if (!views.some((v) => v.id === r.view) && !moreViews.some((v) => v.id === r.view)) r.view = 'roadmap';
102
113
  // A phone shows Roadmap or List only, and has no view switch (pm/mobile.css): its screen is one job, to see where the
103
114
  // initiatives stand and open one. ⌘K changes between the two. Any other view falls back to Roadmap here without being
104
115
  // saved, so the desktop keeps its choice.
105
116
  if (phone() && !BUILT_IN.includes(r.view)) r.view = 'roadmap';
117
+ const inMore = moreViews.some((v) => v.id === r.view);
106
118
  r.key = keyOf(r, repo, ws);
107
119
 
108
120
  const head = el('div', { class: 'in-head' },
109
121
  el('h2', { class: 'in-head__title' }, COPY.title),
110
- tabs({ variant: 'segmented', role: 'tablist', ariaLabel: COPY.views, items: views, value: r.view, attrs: { 'data-in-views': '' }, onChange: (id) => { r.view = id; saveView(id); r.draw(r.ctx); } }),
122
+ tabs({ variant: 'segmented', role: 'tablist', ariaLabel: COPY.views, items: views, value: inMore ? 'more' : r.view, attrs: { 'data-in-views': '' }, onChange: (id) => { r.view = id === 'more' ? moreViews[0].id : id; saveView(r.view); r.draw(r.ctx); } }),
123
+ inMore ? tabs({ variant: 'segmented', role: 'tablist', ariaLabel: 'More views', items: moreViews, value: r.view, attrs: { 'data-in-more': '' }, onChange: (id) => { r.view = id; saveView(id); r.draw(r.ctx); } }) : null,
111
124
  // Scan repository (scan-documents S7): the visible form of the sheet menu's and ⌘K's re-scan. It needs a workspace.
112
125
  ws ? button({ label: COPY.rescan, icon: 'refresh-cw', attrs: { 'data-in-scan': '', title: COPY.rescanHint }, onClick: () => rescanRepo(ws) }) : null,
113
126
  button({ label: COPY.newInitiative, icon: 'plus', variant: 'primary', attrs: { 'data-in-new': '' }, onClick: () => r.openNew() }));
@@ -19,6 +19,8 @@ import { icon, ensureSprite } from './icons.js';
19
19
  import { pmPost } from './http.js';
20
20
  import { wsOf } from './data.js';
21
21
  import { registerCommand, openDestination } from './registry.js';
22
+ import { mountPmWork, disposePmWork } from './pm-work-view.js';
23
+ import { mountSessions, disposeSessions } from './sessions-panel.js';
22
24
  import { myWork, loadMyWork, onMyWork } from './my-work-data.js';
23
25
 
24
26
  export const COPY = Object.freeze({
@@ -112,12 +114,16 @@ export function mountDestination(host, ctx) {
112
114
  ensureSprite();
113
115
  loadMyWork();
114
116
  draw(inst);
117
+ if (!inst.items) { inst.items = el('div', { 'data-pm-items-host': '' }); inst.root.appendChild(inst.items); mountPmWork(inst.items, ctx); }
118
+ if (!inst.sessions) { inst.sessions = el('div', { 'data-sessions-host': '' }); inst.root.appendChild(inst.sessions); mountSessions(inst.sessions, ctx); }
115
119
  }
116
120
 
117
121
  export function disposeDestination(host) {
118
122
  const inst = insts.get(host);
119
123
  if (!inst) return;
120
124
  if (inst.off) inst.off();
125
+ if (inst.items) disposePmWork(inst.items);
126
+ if (inst.sessions) disposeSessions(inst.sessions);
121
127
  inst.root.remove();
122
128
  insts.delete(host);
123
129
  }
@@ -277,7 +283,8 @@ function draw(inst) {
277
283
  const root = inst.root;
278
284
  const d = myWork();
279
285
  const head = header(inst, d);
280
- if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
286
+ // The PM items panel lives in the root too; a redraw of head and body must keep it.
287
+ if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body, ...(inst.items ? [inst.items] : []), ...(inst.sessions ? [inst.sessions] : []));
281
288
  const fp = JSON.stringify([d, inst.tab, inst.open, inst.details, inst.busy]);
282
289
  if (fp === inst.last) return;
283
290
  // Focus stays in an instruction being typed: the redraw waits until the field is left.
@@ -207,7 +207,7 @@ async function refresh(inst) {
207
207
  if (seq !== inst.seq) return;
208
208
  if (r && r.ok) { inst.rows = r.notes || []; inst.error = null; } else { inst.rows = inst.rows || []; inst.error = (r && r.error) || COPY.readError; }
209
209
  if (!inst.open && inst.rows.length) inst.open = inst.rows[0].id;
210
- if (one && one.ok && one.note && one.note.id === inst.open) inst.note = one.note;
210
+ if (one && one.ok && one.note && one.note.id === inst.open) { inst.note = one.note; inst.rec = one.recording || null; }
211
211
  else if (inst.open && !one) loadOpen(inst);
212
212
  drawLeft(inst);
213
213
  drawRight(inst);
@@ -217,8 +217,25 @@ async function loadOpen(inst) {
217
217
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
218
218
  if (inst.open !== id) return;
219
219
  inst.note = r && r.ok ? r.note : null;
220
+ inst.rec = r && r.ok ? r.recording || null : null;
220
221
  drawRight(inst);
221
222
  }
223
+ /** The replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on. Null when no audio is kept. */
224
+ function replayFor(inst, n) {
225
+ const rec = inst.rec;
226
+ if (!rec || n.private) { inst.audio = null; return null; }
227
+ const key = n.id + ':' + rec.bytes + ':' + (rec.cleaned ? 1 : 0);
228
+ if (inst.audio && inst.audio.key === key) return inst.audio.el;
229
+ if (inst.audio && !inst.audio.el.paused) return inst.audio.el; // never cut a replay that is playing
230
+ const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-ntm-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes + (rec.cleaned ? '&c=1' : '') });
231
+ inst.audio = { key, el: a };
232
+ return a;
233
+ }
234
+ function seekReplay(inst, ms) {
235
+ const a = inst.audio && inst.audio.el;
236
+ if (!a) return;
237
+ try { a.currentTime = Math.max(0, ms / 1000); a.play(); } catch { /* the audio is not ready */ }
238
+ }
222
239
  function choose(inst, id) {
223
240
  if (inst.open === id) return;
224
241
  inst.open = id; inst.note = null; inst.ctxOpen = null; inst.info = false;
@@ -259,7 +276,7 @@ function drawRight(inst, force) {
259
276
  const n = inst.note;
260
277
  const live = dock.recordState();
261
278
  const held = !!n && live.mode !== 'idle' && live.id === n.id;
262
- const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
279
+ const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], inst.rec && [inst.rec.bytes, inst.rec.cleaned], live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
263
280
  // a held note draws again on every render: the clock, the lines and the paused minutes move. Nothing else draws without a change.
264
281
  if (!force && !held && sig === inst.rightSig) return;
265
282
  // a field being typed in is not redrawn under the cursor, except in the live block, whose fields are kept across redraws
@@ -387,6 +404,11 @@ function noteParts(inst, n, live) {
387
404
  if (V.renderLive(liveHost, { state: inst.ctx.state, setState: inst.ctx.setState, FIXTURE: inst.ctx.FIXTURE }, n.id)) out.push(liveHost);
388
405
  }
389
406
 
407
+ const replay = held ? null : replayFor(inst, n);
408
+ if (replay) out.push(el('section', { class: 'nt-pg-sec nt-pg-sec--first nt-pg-replay', 'data-ntm': 'replay' },
409
+ el('h3', { class: 'nt-pg-sec__h' }, el('span', null, 'Replay')), replay,
410
+ el('p', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the discussion to play from there.')));
411
+
390
412
  const cf = conflictsOf(n);
391
413
  if (cf.length) {
392
414
  out.push(el('section', { class: 'nt-pg-conflicts', 'data-ntm': 'conflicts' },
@@ -438,7 +460,7 @@ function noteParts(inst, n, live) {
438
460
  out.push(el('details', { class: 'nt-pg-sec nt-pg-fold', 'data-ntm': 'discussion' },
439
461
  el('summary', { class: 'nt-pg-fold__sum' }, COPY.discussion + ' · ' + bits),
440
462
  p.topics.map((tp) => el('div', { class: 'nt-pg-topic' }, el('h4', { class: 'nt-pg-topic__h' }, tp.title), (tp.points || []).map((pt) => el('p', { class: 'nt-pg-topic__p' }, pt)))),
441
- tr.length ? el('div', { class: 'nt-pg-trans' }, tr.map((x) => el('div', { class: 'nt-pg-li' }, el('span', { class: 'nt-pg-li__at' }, V.fmtTime(x.t || 0)), el('span', { class: 'nt-pg-li__text' }, x.text)))) : null));
463
+ tr.length ? el('div', { class: 'nt-pg-trans' }, tr.map((x) => el('div', { class: 'nt-pg-li' }, inst.audio && !held ? button({ label: V.fmtTime(x.t || 0), variant: 'ghost', size: 'sm', onClick: () => seekReplay(inst, x.t || 0), attrs: { 'data-ntm-seek': '', title: 'Play from here' } }) : el('span', { class: 'nt-pg-li__at' }, V.fmtTime(x.t || 0)), el('span', { class: 'nt-pg-li__text' }, x.text)))) : null));
442
464
  }
443
465
  return out.filter(Boolean);
444
466
  }
@@ -25,6 +25,11 @@ import { wsOf } from './data.js';
25
25
  import { COPY, cadenceName } from './reports-copy.js';
26
26
  import { openIssueSheet, openNotesSheet, draftNow, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
27
27
  import { openSlides, slideCards } from './report-slides.js';
28
+ /* The report families and the outbox load on demand: they are only drawn when the PM opens the toggle, and their module tree (the c3
29
+ * panels) must not weigh on the first paint of the Reports page. `lazy()` resolves both modules once. */
30
+ let lazyMods = null;
31
+ const lazy = () => lazyMods || (lazyMods = Promise.all([import('./report-families-view.js'), import('./outbox-panel.js')]).then(([f, o]) => ({ ...f, ...o })));
32
+ const disposeLazy = (fn, host) => { if (lazyMods) lazyMods.then((m) => m[fn](host)); };
28
33
 
29
34
  const SHOWN = 4; // the reports listed before "Show older"
30
35
  const insts = new WeakMap(); // host -> instance
@@ -49,6 +54,7 @@ function make(host) {
49
54
  const inst = {
50
55
  host, root, ws: null, loaded: false, error: null, reports: [], flags: [], sig: null, schedules: [], doc: null, docKey: null,
51
56
  sel: null, showAll: false, otherOpen: false, locked: false, stop: null, seq: 0, last: '',
57
+ extra: null, famHost: null, outHost: null, extraWs: null,
52
58
  };
53
59
  return inst;
54
60
  }
@@ -72,17 +78,56 @@ export function mountDestination(host, ctx) {
72
78
  refresh(inst);
73
79
  }
74
80
  draw(inst);
81
+ mountExtras(inst, ctx);
75
82
  return;
76
83
  }
77
84
  if (!inst.last) draw(inst);
85
+ mountExtras(inst, ctx);
78
86
  }
79
87
 
88
+ /* Report families and the outbox sit under the page. They live in one container the page's own redraw never replaces
89
+ * (draw() puts it back last), so a redraw does not lose them. Mounted once per repository: the families read their sections
90
+ * from GET /api/report-families/facts (real initiatives and releases only), then the outbox. */
91
+ function mountExtras(inst, ctx) {
92
+ if (!inst.ws || inst.extraWs === inst.ws) return;
93
+ inst.extraWs = inst.ws;
94
+ if (!inst.extra) {
95
+ inst.famHost = el('div', { 'data-reports-families': '' });
96
+ inst.outHost = el('div', { 'data-reports-outbox': '' });
97
+ inst.panel = el('div', { class: 'rp-extra__panel', hidden: true }, inst.famHost, inst.outHost);
98
+ // Collapsed by default: the page's control budget (first-paint, pm-frame Q5) counts what a first paint shows, so the
99
+ // families and the outbox cost one control until the PM opens them.
100
+ inst.toggle = button({ label: 'Report families and outbox', variant: 'ghost', attrs: { 'data-reports-extra-toggle': '', 'aria-expanded': 'false' }, onClick: () => toggleExtras(inst, ctx) });
101
+ inst.extra = el('div', { class: 'rp-extra', 'data-reports-extra': '' }, inst.toggle, inst.panel);
102
+ }
103
+ keepExtra(inst);
104
+ // A repository change resets to collapsed, with nothing mounted: the families read their own facts when opened.
105
+ inst.extraOpen = false; inst.panel.hidden = true; inst.toggle.setAttribute('aria-expanded', 'false');
106
+ disposeLazy('disposeReportFamilies', inst.famHost); disposeLazy('disposeOutbox', inst.outHost);
107
+ }
108
+ function toggleExtras(inst, ctx) {
109
+ inst.extraOpen = !inst.extraOpen;
110
+ inst.panel.hidden = !inst.extraOpen;
111
+ inst.toggle.setAttribute('aria-expanded', String(inst.extraOpen));
112
+ if (!inst.extraOpen || !inst.ws) return;
113
+ const ws = inst.ws;
114
+ disposeLazy('disposeReportFamilies', inst.famHost);
115
+ pmGet('/api/report-families/facts?ws=' + encodeURIComponent(ws)).then((j) => {
116
+ if (inst.extraWs !== ws || !inst.extraOpen || !insts.has(inst.host)) return;
117
+ lazy().then((m) => { if (inst.extraWs === ws && inst.extraOpen) m.mountReportFamilies(inst.famHost, { ...(ctx || {}), reportFacts: j && j.ok ? j.facts : {} }); });
118
+ });
119
+ lazy().then((m) => { if (inst.extraOpen) m.mountOutbox(inst.outHost, {}); });
120
+ }
121
+ const keepExtra = (inst) => { if (inst.extra && inst.ws && inst.extra.parentNode !== inst.root) inst.root.appendChild(inst.extra); };
122
+
80
123
  export function disposeDestination(host) {
81
124
  const inst = insts.get(host);
82
125
  if (!inst) return;
83
126
  if (inst.stop) inst.stop();
84
127
  inst.stop = null; inst.seq++;
85
128
  clearPrint();
129
+ if (inst.famHost) disposeLazy('disposeReportFamilies', inst.famHost);
130
+ if (inst.outHost) disposeLazy('disposeOutbox', inst.outHost);
86
131
  inst.root.remove();
87
132
  insts.delete(host);
88
133
  }
@@ -309,7 +354,8 @@ function goFullScreen() {
309
354
  }
310
355
 
311
356
  /** Rebuild the persistent root from the instance, if what it shows changed; keep the focus on the same control. */
312
- function draw(inst) {
357
+ function draw(inst) { drawMain(inst); keepExtra(inst); }
358
+ function drawMain(inst) {
313
359
  const root = inst.root;
314
360
  if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
315
361
  const fingerprint = JSON.stringify([inst.loaded, inst.error, inst.reports, inst.flags, inst.schedules, inst.sel, inst.doc && inst.doc.sha, inst.showAll, inst.otherOpen, inst.locked]);
@@ -22,6 +22,8 @@ import { state, setState, FIXTURE } from './state.js';
22
22
  import { COPY } from './today-copy.js';
23
23
  import { todayCard, acceptCard, rankNodes, scopeCtx, ensureFeed, feedOf, feedErrorOf, bannerOf, onFeed } from './today-cards.js';
24
24
  import { daySection, daySig, dayState, pressPrepare } from './today-day.js';
25
+ import { mountBriefing, disposeBriefing } from './briefing-deck.js';
26
+ import { mountCalendarViews, disposeCalendarViews } from './calendar-views.js';
25
27
 
26
28
  const SHOWN = 3; // cards drawn before "Show more": the first paint holds at most 17 controls (first-paint-today.test.js)
27
29
  let expanded = false;
@@ -205,8 +207,60 @@ export function mountToday(host, ctx) {
205
207
  refocus(r.root, k);
206
208
  }
207
209
 
208
- export function mountDestination(host, ctx) { mountToday(host, ctx); }
210
+ /* The destination: Today's own board, plus Briefing and Calendar (console3 wave D) as two more views of the same page.
211
+ * Each view has its own pane in one wrapper that survives every redraw (the container-survives-redraw rule: dest-mywork.js
212
+ * keeps inst.items the same way). Briefing and Calendar mount once, on first open, into their pane and are disposed with the
213
+ * destination; Today keeps drawing into its pane exactly as before, so its content is unchanged. */
214
+ const VIEWS = [{ id: 'today', label: 'Today' }, { id: 'briefing', label: 'Briefing' }, { id: 'calendar', label: 'Calendar' }];
215
+ const dests = new WeakMap(); // host -> { wrap, bar, panes, view, mounted }
216
+
217
+ function destFor(host, ctx) {
218
+ let d = dests.get(host);
219
+ if (d) return d;
220
+ const panes = {};
221
+ for (const v of VIEWS) panes[v.id] = el('div', { class: 'td-view', 'data-today-view': v.id });
222
+ d = { wrap: null, bar: null, panes, view: 'today', mounted: {} };
223
+ const bar = tabs({
224
+ variant: 'segmented', role: 'radiogroup', ariaLabel: 'Today views', value: d.view,
225
+ items: VIEWS.map((v) => ({ id: v.id, label: v.label })),
226
+ onChange: (id) => { d.view = VIEWS.some((v) => v.id === id) ? id : 'today'; showView(host, d, d.ctx); },
227
+ attrs: { 'data-today': 'views' },
228
+ });
229
+ d.bar = bar;
230
+ d.wrap = el('div', { class: 'td-dest', 'data-today': 'dest' }, bar, ...VIEWS.map((v) => panes[v.id]));
231
+ dests.set(host, d);
232
+ return d;
233
+ }
234
+
235
+ function showView(host, d, ctx) {
236
+ for (const v of VIEWS) d.panes[v.id].hidden = v.id !== d.view;
237
+ d.bar.querySelectorAll('button[data-tab]').forEach((b) => { // the segmented control does not track its own choice
238
+ const on = b.getAttribute('data-tab') === d.view;
239
+ b.setAttribute('aria-checked', on ? 'true' : 'false'); b.setAttribute('tabindex', on ? '0' : '-1');
240
+ });
241
+ if (d.view === 'today') mountToday(d.panes.today, ctx);
242
+ else if (d.view === 'briefing') { d.mounted.briefing = true; mountBriefing(d.panes.briefing, ctx); }
243
+ else if (d.view === 'calendar') { d.mounted.calendar = true; mountCalendarViews(d.panes.calendar, ctx); }
244
+ }
245
+
246
+ export function mountDestination(host, ctx) {
247
+ if (!host) return;
248
+ const d = destFor(host, ctx);
249
+ d.ctx = ctx;
250
+ if (!d.wrap.isConnected || d.wrap.parentNode !== host) host.replaceChildren(d.wrap);
251
+ showView(host, d, ctx);
252
+ }
209
253
  export function disposeDestination(host) {
254
+ const d = dests.get(host);
255
+ if (d) {
256
+ for (const v of VIEWS) disposeToday(d.panes[v.id]);
257
+ if (d.mounted.briefing) disposeBriefing(d.panes.briefing);
258
+ if (d.mounted.calendar) disposeCalendarViews(d.panes.calendar);
259
+ dests.delete(host);
260
+ }
261
+ disposeToday(host);
262
+ }
263
+ function disposeToday(host) {
210
264
  const r = roots.get(host);
211
265
  if (r && r.off) r.off();
212
266
  roots.delete(host);
@@ -0,0 +1,110 @@
1
+ /* pm/doc-health-panel.js — console3 wave B2: the document's Health and History modes, and the Ask-the-agent diff.
2
+ *
3
+ * Health: "N of M checks" and the reason for each. A check with no input is shown as "not enough input" and is not
4
+ * in N or M. The result is recomputed by the server from the text on each read and is never stored here beyond a short hold.
5
+ * Fix with the agent: disabled until the agent session path is built. Ask the agent: the word diff of a before and an
6
+ * after, with Accept disabled (accept by slide needs the outbox and the slide, which are not built).
7
+ * History: the word diff between two revisions of the document, given as a list { id, text, label? }.
8
+ */
9
+ import { el } from './dom.js';
10
+ import { setState } from './state.js';
11
+ import { ensureC3Css, c3ic } from './pm-work-view.js';
12
+
13
+ /* R4b: the panels are drawn on the console3 mockup's document sheet markup (console3.html docPanel, lines 4090 to 4104; the diff,
14
+ * lines 4075 to 4076; the revision list, line 4102): `.dpn` holds `.chips2` (the shield and "N of M checks pass"), `ul.chgl`
15
+ * rows (`li.ok` or `li.bad` with the check and its reason), `ul.hx` for the revisions, and `.dpn.dfi > .dsec > h4 + p` with
16
+ * `<ins>` and `<del>` for the diff. The styles are pm/console3.css. The document panel mounts in the document rail, which has no
17
+ * `.c3` root, so each panel carries `c3 c3e`. Not lifted: the mockup's Read, Edit and Relations modes (the rail has its own),
18
+ * the per-check "Fix with the agent" chips (one disabled button stays, there is no agent session path), the section flags,
19
+ * and the document graph (no relations data is served here). */
20
+ const wrap = (attrs, kids) => { ensureC3Css(); return el('div', { class: 'c3 c3e', ...attrs }, el('div', { class: 'dpn' }, kids)); };
21
+ const hcol = (h) => (h.total === 0 ? 'var(--muted-foreground)' : h.passed === h.total ? 'var(--success)' : h.total - h.passed === 1 ? 'var(--warning)' : 'var(--destructive)');
22
+
23
+ const held = new Map(); // ws|path -> { state, data, error, at }
24
+
25
+ const NAMES = { fresh: 'Fresh', decisions: 'Matches decisions', data: 'Matches data', complete: 'Complete', traced: 'Traced', readers: 'Readers have the current version' };
26
+
27
+ function readHealth(ws, rel) {
28
+ const k = ws + '|' + rel;
29
+ const cur = held.get(k);
30
+ if (cur && (cur.state === 'loading' || Date.now() - cur.at < 30000)) return cur;
31
+ const next = { state: 'loading', data: cur ? cur.data : null, at: Date.now() };
32
+ held.set(k, next);
33
+ fetch('/api/doc-health/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ws, path: rel }) })
34
+ .then((r) => r.json())
35
+ .then((d) => { held.set(k, d && d.ok ? { state: 'ok', data: d.health, at: Date.now() } : { state: 'error', error: (d && d.error) || 'refused', at: Date.now() }); setState({}); },
36
+ (e) => { held.set(k, { state: 'error', error: (e && e.message) || 'could not reach the workspace server', at: Date.now() }); setState({}); });
37
+ return next;
38
+ }
39
+
40
+ /** What the Health mode shows now, for the rail's change key. */
41
+ export function healthStamp(ws, rel) { const g = held.get(ws + '|' + rel); return g ? [g.state, g.error || null, g.data || null] : null; }
42
+
43
+ export function refreshHealth(ws, rel) { held.delete(ws + '|' + rel); }
44
+
45
+ /** The pure view of one healthOf answer. */
46
+ export function healthView(h) {
47
+ const note = h.checks.filter((c) => c.pass === null).length;
48
+ return wrap({ 'data-rail': 'health' }, [
49
+ el('div', { class: 'chips2', 'data-health': 'count' }, [el('span', { class: 'ki', style: 'color:' + hcol(h) }, [c3ic('shield')]), el('b', null, `${h.passed} of ${h.total} checks pass`)]),
50
+ note ? el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0', 'data-health': 'unanswered' }, `${note} check${note === 1 ? '' : 's'} not counted: not enough input.`) : null,
51
+ el('ul', { class: 'chgl' }, h.checks.map((c) => el('li', { class: c.pass === null ? '' : c.pass ? 'ok' : 'bad', 'data-check': c.id, 'data-pass': c.pass === null ? 'none' : String(c.pass) }, [
52
+ c3ic(c.pass === null ? 'info' : c.pass ? 'check' : 'alert', 'sm'),
53
+ el('span', null, (NAMES[c.id] || c.id) + ': ' + c.reason),
54
+ el('span', { class: 'tag' }, c.pass === null ? 'no input' : c.pass ? 'pass' : 'fail'),
55
+ ]))),
56
+ el('div', { class: 'chips2' }, [el('button', { type: 'button', class: 'pill', disabled: true, 'aria-disabled': 'true', 'data-health': 'fix',
57
+ title: 'Needs the agent session path, which is not built yet.' }, [c3ic('spark', 'sm'), 'Fix with the agent'])]),
58
+ el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Health answers one question: can I trust this document without rereading it? Checks are recomputed from the text every time it changes.'),
59
+ ]);
60
+ }
61
+
62
+ /** The Health mode for one document. */
63
+ export function healthPanel(o) {
64
+ const g = readHealth(o.ws, o.rel);
65
+ if (g.state === 'loading' && !g.data) return wrap({ 'data-rail': 'status' }, [el('div', { class: 'fold a' }, [el('span', { class: 'c3spin' }, [c3ic('spark', 'sm')]), 'Checking this document…'])]);
66
+ if (g.state === 'error') return wrap({ 'data-rail': 'status' }, [el('div', { class: 'fold w' }, [c3ic('alert', 'sm'), 'The checks could not run: ' + g.error + '. Nothing is shown.'])]);
67
+ return healthView(g.data);
68
+ }
69
+
70
+ const diffNode = (diff) => el('div', { class: 'dpn dfi', 'data-rail': 'diff' }, el('div', { class: 'dsec' + (diff.some((d) => d.op !== 'same') ? ' warn' : '') }, el('p', null, diff.map((d, i) => {
71
+ const tag = d.op === 'add' ? 'ins' : d.op === 'del' ? 'del' : 'span';
72
+ return [i ? ' ' : '', el(tag, { 'data-op': d.op }, d.text)];
73
+ }).flat())));
74
+
75
+ // The same plain LCS as scripts/doc-health.js, kept here because the page cannot load a CommonJS file.
76
+ export function wordDiff(before, after) {
77
+ const a = String(before || '').split(/\s+/).filter(Boolean), b = String(after || '').split(/\s+/).filter(Boolean);
78
+ const L = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
79
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
80
+ const out = [];
81
+ const push = (op, text) => { const l = out[out.length - 1]; if (l && l.op === op) l.text += ' ' + text; else out.push({ op, text }); };
82
+ let i = 0, j = 0;
83
+ while (i < a.length && j < b.length) {
84
+ if (a[i] === b[j]) { push('same', a[i]); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) push('del', a[i++]); else push('add', b[j++]);
85
+ }
86
+ while (i < a.length) push('del', a[i++]);
87
+ while (j < b.length) push('add', b[j++]);
88
+ return out;
89
+ }
90
+
91
+ /** History mode: compare the newest revision with the one before it (or the pair the PM picked in `o.pick`). */
92
+ export function historyPanel(o) {
93
+ const revs = Array.isArray(o.revisions) ? o.revisions : [];
94
+ if (revs.length < 2) return wrap({ 'data-rail': 'history' }, [el('div', { class: 'fold' }, [c3ic('info', 'sm'), 'Fewer than two revisions are known for this document, so there is nothing to compare.'])]);
95
+ const [from, to] = o.pick || [revs[revs.length - 2].id, revs[revs.length - 1].id];
96
+ const a = revs.find((r) => r.id === from), b = revs.find((r) => r.id === to);
97
+ if (!a || !b) return wrap({ 'data-rail': 'history' }, [el('div', { class: 'fold w' }, [c3ic('alert', 'sm'), 'That revision is not in the list.'])]);
98
+ return wrap({ 'data-rail': 'history' }, [
99
+ el('ul', { class: 'hx' }, revs.slice().reverse().map((r) => el('li', null, [el('span', { class: 'chip2' }, [c3ic('doc', 'sm'), r.id]), el('span', null, [el('b', null, r.label || r.id)]), el('span', { class: 'w' }, r.id === to ? 'newest compared' : r.id === from ? 'compared with' : '')]))),
100
+ el('h4', { style: 'margin:0;font-size:var(--fs-3)' }, `${a.label || a.id} to ${b.label || b.id}`),
101
+ diffNode(wordDiff(a.text, b.text)),
102
+ ]);
103
+ }
104
+
105
+ /** Ask the agent: the word diff of a supplied before and after. Accept needs the outbox and the slide. */
106
+ export function askPanel({ before, after }) {
107
+ return wrap({ 'data-rail': 'ask' }, [diffNode(wordDiff(before, after)),
108
+ el('div', { class: 'chips2' }, [el('button', { type: 'button', class: 'pill', disabled: true, 'aria-disabled': 'true', 'data-ask': 'accept',
109
+ title: 'Accept by slide needs the outbox and the slide, which are not built yet.' }, [c3ic('check', 'sm'), 'Accept'])])]);
110
+ }
@@ -14,11 +14,12 @@ import { el, setChildren } from './dom.js';
14
14
  import { tabs, row } from './primitives.js';
15
15
  import { setState } from './state.js';
16
16
  import { copiesOf, wordsOf, openCopy } from './doc-remote.js';
17
+ import { healthPanel, historyPanel, healthStamp } from './doc-health-panel.js';
17
18
 
18
19
  const SVG = 'http://www.w3.org/2000/svg';
19
20
  const graphs = new Map(); // ws|path -> { state, data, at }
20
21
  const rails = new Map(); // tab id -> { node, key }
21
- const modes = new Map(); // tab id -> 'outline' | 'graph'
22
+ const modes = new Map(); // tab id -> 'outline' | 'graph' | 'health' | 'history'
22
23
 
23
24
  const VIA = { link: 'Link', frontmatter: 'Frontmatter', initiative: 'Initiative', anchor: 'Anchor', cites: 'Decision',
24
25
  supersedes: 'Supersedes', amended_by: 'Amended by' };
@@ -51,7 +52,7 @@ export function railNode(tab, o) {
51
52
  const ties = g.state === 'ok' && g.data ? g.data.ties || [] : [];
52
53
  const flagged = new Set(o.findings.map((f) => f.rel).filter(Boolean));
53
54
  const mode = modes.get(tab.id) || (o.heads.length > 1 ? 'outline' : 'graph');
54
- const key = JSON.stringify([mode, o.heads, g.state, ties, copies.map((c) => [c.kind, wordsOf(c, o.ws, o.rel).text]), o.findings]);
55
+ const key = JSON.stringify([mode, mode === 'health' ? healthStamp(o.ws, o.rel) : 0, o.revisions, o.heads, g.state, ties, copies.map((c) => [c.kind, wordsOf(c, o.ws, o.rel).text]), o.findings]);
55
56
  let r = rails.get(tab.id);
56
57
  if (!r) { r = { node: el('aside', { class: 'fr-rail', 'data-rail': '' }), key: null }; rails.set(tab.id, r); }
57
58
  if (r.key === key) return r.node;
@@ -59,7 +60,8 @@ export function railNode(tab, o) {
59
60
  const n = ties.length + copies.length;
60
61
  const kids = [tabs({
61
62
  variant: 'segmented', ariaLabel: 'Beside the document', value: mode, full: true,
62
- items: [{ id: 'outline', label: 'Outline' }, { id: 'graph', label: n ? 'Connections ' + n : 'Connections' }],
63
+ items: [{ id: 'outline', label: 'Outline' }, { id: 'graph', label: n ? 'Connections ' + n : 'Connections' },
64
+ { id: 'health', label: 'Health' }, { id: 'history', label: 'History' }],
63
65
  onChange: (m) => { modes.set(tab.id, m); setState({}); },
64
66
  attrs: { class: 'fr-rail__tabs', 'data-rail': 'tabs' },
65
67
  })];
@@ -69,6 +71,10 @@ export function railNode(tab, o) {
69
71
  o.heads.map((h) => row({ title: h.text, compact: true, onOpen: () => o.onGoto(h.i), openLabel: 'Go to ' + h.text,
70
72
  attrs: { class: 'fr-rail__head', 'data-level': String(h.level), 'data-rail': 'head' } })))
71
73
  : el('p', { class: 'fr-rail__note' }, 'No headings yet. A line that starts with # or ## is a heading.'));
74
+ } else if (mode === 'health') {
75
+ kids.push(healthPanel(o));
76
+ } else if (mode === 'history') {
77
+ kids.push(historyPanel(o));
72
78
  } else if (g.state === 'loading' && !g.data) {
73
79
  kids.push(el('p', { class: 'fr-rail__note', 'data-rail': 'status' }, 'Reading the ties of this file…'));
74
80
  } else if (g.state === 'error') {