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
@@ -0,0 +1,249 @@
1
+ /* pm/c3-roadmap.js — the Roadmap sub-view of Initiatives on /c3.html, on the mockup's own markup and CSS (console3.html: rmRow line 3765, genRm 3778,
2
+ * viewLeft 3840, viewRight 3848, roadSVG 3754, rmBar 4485, calSyncCard 3795).
3
+ * Left: the Plan lens and deck, an intro card with the plus (add a bet), then Now, Next and Later (`.card.sect.rmcol`) with a row per bet and a
4
+ * case file on tap. Right: By quarter (the SVG), Capacity (bets in flight, of the limit 4) and On your Calendar (dates that reach it).
5
+ * REAL: bets from GET /api/c3-init/roadmap (the bets records; capacity derived from people), writes through pmPost to /api/roadmap-ops/bet/add,
6
+ * /move and /take-off exactly as that route's header says. A move into a full column is answered with a PROPOSAL (which bet goes back); nothing
7
+ * moves until the PM presses "Confirm the swap" (confirmSwap: true). No bet record yet: each column shows the repository's own roadmap rows
8
+ * (ctx.rows) read-only and says so; nothing is invented. Dates on the Calendar card come from GET /api/forecast for the Now initiatives. */
9
+ import { el } from './dom.js';
10
+ import { pmGet, pmPost } from './http.js';
11
+ import { c3ic, c3ib } from './pm-work-view.js';
12
+ import { makeView, frame, hBars, card, emptyRow, short, dayLabel, rowShell, caseNode, markDone, say } from './c3-init-extras.js';
13
+ import { mountSlide } from './slide-confirm.js';
14
+
15
+ const COLS = ['now', 'next', 'later'];
16
+ const CL = { now: 'Now', next: 'Next', later: 'Later' };
17
+ const CS = { now: 'Committed, in build or about to be', next: 'Likely, one quarter out', later: 'Direction only, no date' };
18
+ const CI = { now: 'bolt', next: 'next', later: 'moon' };
19
+ const LIMIT = 4;
20
+ const NSV = 'http://www.w3.org/2000/svg';
21
+ const confColor = (c) => (c >= 0.7 ? 'var(--success)' : c >= 0.45 ? 'var(--warning)' : 'var(--destructive)');
22
+ const q0Of = (b) => (b.q0 != null ? b.q0 : b.horizon === 'now' ? 0 : b.horizon === 'next' ? 1 : 2);
23
+ const q1Of = (b) => (b.q1 != null ? b.q1 : b.horizon === 'later' ? 3 : q0Of(b));
24
+ const quarters = () => { const d = new Date(); let q = Math.floor(d.getMonth() / 3); let y = d.getFullYear(); const out = []; for (let i = 0; i < 4; i++) { out.push('Q' + (q + 1) + ' ' + String(y).slice(2)); q++; if (q > 3) { q = 0; y++; } } return out; };
25
+
26
+ /** A bet's delay word: the bet's own `risk` hint when it has one, else the repository row's health. Only Now bets show it. */
27
+ function riskOf(V, b) {
28
+ if (b.horizon !== 'now') return null;
29
+ const r = b.risk || (() => { const row = (V.ctx.rows || []).find((x) => x.id === b.initiative); const l = row && row.health && row.health.level; return l === 'at-risk' ? 'risk' : /off|block|delay|late/.test(l || '') ? 'late' : ''; })();
30
+ return r === 'late' ? { l: 'Delayed', c: 'bad', i: 'clock' } : r === 'risk' ? { l: 'At risk', c: 'warn', i: 'alert' } : null;
31
+ }
32
+ const krTitle = (V, id) => { const k = ((V.data.rd && V.data.rd.krs) || []).find((x) => x.id === id); return k ? k.title : id; };
33
+ const betsIn = (V) => (((V.data.rd && V.data.rd.bets) || []).filter((b) => !b.off && (!V.scope || b.initiative === V.scope)));
34
+
35
+ async function load(V) {
36
+ const rd = await pmGet('/api/c3-init/roadmap').catch(() => null);
37
+ if (V.disposed) return;
38
+ V.data.rd = rd && rd.ok ? rd : null; V.data.err = rd && rd.ok ? null : (rd && rd.error) || 'The roadmap could not be read.';
39
+ V.render();
40
+ const ids = (V.ctx.rows || []).filter((r) => r.horizon === 'now').map((r) => r.id).slice(0, 4);
41
+ const fcs = await Promise.all(ids.map((id) => pmGet('/api/forecast?initiative=' + encodeURIComponent(id)).catch(() => null)));
42
+ if (V.disposed) return;
43
+ V.data.fc = ids.map((id, i) => [id, fcs[i]]).filter(([, j]) => j && j.ok);
44
+ V.render();
45
+ }
46
+
47
+ async function press(V, path, body, done, at) {
48
+ const r = await pmPost(path, body);
49
+ if (V.disposed) return r;
50
+ if (r && r.ok && r.proposal) { V.proposal = { ...r.proposal }; V.open['rm:' + (r.proposal.move && r.proposal.move.id)] = true; V.note = ''; V.render(); return r; }
51
+ if (r && r.ok) { V.proposal = null; V.note = done || 'Done.'; V.err = ''; V.errAt = null; await load(V); return r; }
52
+ V.err = (r && r.error) || 'Not recorded.'; V.errAt = at || null; if (at) V.open['rm:' + at] = true; V.render(); return r;
53
+ }
54
+
55
+ const live = (V, col) => ((V.data.rd && V.data.rd.bets) || []).filter((x) => !x.off && x.horizon === col);
56
+ /** The lowest-confidence bet of a column: the one a full column sends back (the route's own rule). */
57
+ const weakest = (V, col, but) => live(V, col).filter((x) => x.rid !== but).sort((p, q) => (typeof p.confidence === 'number' ? p.confidence : 0.5) - (typeof q.confidence === 'number' ? q.confidence : 0.5))[0] || null;
58
+
59
+ /** Move a bet one column (the page press). A full column answers with a proposal: it is shown on the row for the PM to confirm, never applied here. */
60
+ async function moveBet(V, b, to, msg) {
61
+ V.proposal = null; V.err = '';
62
+ const from = b.horizon;
63
+ const r = await press(V, '/api/roadmap-ops/bet/move', { id: b.rid, to }, msg, b.rid);
64
+ if (r && r.ok && !r.proposal) say(msg, () => press(V, '/api/roadmap-ops/bet/move', { id: b.rid, to: from }, 'Moved back to ' + CL[from] + '.', b.rid));
65
+ return r;
66
+ }
67
+
68
+ /* ── one row of a column ── */
69
+ function betRow(V, b) {
70
+ const risk = riskOf(V, b); const QL = quarters(); const key = 'rm:' + b.rid; const i = COLS.indexOf(b.horizon);
71
+ const conf = typeof b.confidence === 'number' ? b.confidence : null;
72
+ /* the swipe: right pulls it forward, left pushes it back (console3.html line 3881). A swipe never applies a swap: the route answers with a proposal and the row opens on it. */
73
+ const edge = () => say('Already at the edge');
74
+ const sw = {
75
+ labelRight: i > 0 ? 'pull it forward to ' + CL[COLS[Math.max(0, i - 1)]] : 'already at the edge', labelLeft: i < 2 ? 'push it back to ' + CL[COLS[Math.min(2, i + 1)]] : 'already at the edge', iconRight: 'next', iconLeft: 'moon',
76
+ onRight: () => (i > 0 ? moveBet(V, b, COLS[i - 1], 'Pulled forward to ' + CL[COLS[i - 1]] + '.') : edge()),
77
+ onLeft: () => (i < 2 ? moveBet(V, b, COLS[i + 1], 'Pushed back to ' + CL[COLS[i + 1]] + '.') : edge()),
78
+ };
79
+ return rowShell(V, { key, attrs: { 'data-rm': b.rid, 'data-id': b.rid }, icon: CI[b.horizon], title: b.title, sw, xpContent: caseFile(V, b),
80
+ chips: [conf == null ? null : el('span', { class: 'chip2', style: 'color:' + confColor(conf), title: 'Confidence' }, c3ic('target', 'sm'), Math.round(conf * 100) + '%'),
81
+ b.parent_kr ? el('span', { class: 'chip2' }, c3ic('flag', 'sm'), short(krTitle(V, b.parent_kr), 26)) : el('span', { class: 'st bad' }, c3ic('alert', 'sm'), 'No anchor'),
82
+ risk ? el('span', { class: 'st ' + risk.c }, c3ic(risk.i, 'sm'), risk.l) : null,
83
+ el('span', { class: 'tag' }, QL[q0Of(b)] + (q1Of(b) > q0Of(b) ? ' to ' + QL[q1Of(b)] : ''))] });
84
+ }
85
+
86
+ /** The case file of a bet (console3.html genRm, line 3778): the situation, the evidence with the capacity check, and each move with what it costs.
87
+ * The slide runs the picked move through the page-press routes; a move into a full column comes back as a swap proposal the PM confirms on its own slide. */
88
+ function caseFile(V, b) {
89
+ const i = COLS.indexOf(b.horizon); const conf = typeof b.confidence === 'number' ? b.confidence : null; const risk = riskOf(V, b); const key = 'rm:' + b.rid;
90
+ const here = live(V, b.horizon).length;
91
+ const chg = [['target', conf == null ? 'Confidence not rated (counts as 50 percent)' : 'Confidence ' + Math.round(conf * 100) + '%', 'agent'],
92
+ ['flag', b.parent_kr ? 'Serves ' + krTitle(V, b.parent_kr) : 'No KR or health budget anchor', 'anchor', b.parent_kr ? '' : 'bad'],
93
+ ['scale', CL[b.horizon] + ' holds ' + here + ' of ' + LIMIT + ' bets', 'capacity', here > LIMIT ? 'bad' : '']];
94
+ if (b.why) chg.push(['info', b.why, 'evidence']);
95
+ const sit = risk && b.horizon === 'now' ? 'This bet is on the roadmap as committed but its date is slipping. Decide whether the roadmap still believes the date.' : (b.why || 'Roadmap items move on evidence and capacity. Each option shows what it costs.');
96
+ const opts = [];
97
+ if (i > 0) {
98
+ const T = COLS[i - 1]; const full = T !== 'later' && live(V, T).length >= LIMIT; const vic = full ? weakest(V, T, b.rid) : null;
99
+ opts.push({ id: 'pf', l: 'Pull it forward to ' + CL[T], rec: !!b.parent_kr && conf != null && conf >= 0.7,
100
+ fx: [b.parent_kr ? ['check', 'Gets capacity and dates', 'ok'] : ['alert', 'No anchor: refused until it has one', 'bad'], ...(full ? [['alert', 'Pushes ' + (vic ? '“' + short(vic.title, 22) + '”' : 'something else') + ' back', 'warn']] : [['scale', CL[T] + ' would hold ' + (live(V, T).length + 1) + ' of ' + LIMIT, 'ok']])] });
101
+ }
102
+ for (const x of (b.options || [])) opts.push({ id: 'x:' + x.id, l: x.l, rec: !!x.rec, fx: x.fx || [] });
103
+ if (i < 2) opts.push({ id: 'pb', l: 'Push it back to ' + CL[COLS[i + 1]], rec: !b.parent_kr, fx: [['clock', 'Frees capacity in ' + CL[b.horizon], 'ok'], ...(b.horizon === 'now' ? [['alert', 'Dates already promised move too', 'warn']] : [])] });
104
+ opts.push({ id: 'off', l: 'Take it off the roadmap', fx: [['clock', 'Frees a slot', 'ok'], ['info', 'A reason is kept in the record', '']] });
105
+ opts.push({ id: 'k', l: 'Keep it where it is', rec: !opts.some((o) => o.rec) && !!b.parent_kr, fx: [] });
106
+ const model = { sit, conf: Math.round(60 + (conf == null ? 0.5 : conf) * 30), chg, opts, cta: 'Slide to confirm' };
107
+ const rm = V.reasons = V.reasons || {};
108
+ const reason = el('input', { type: 'text', placeholder: 'Why is it coming off?', 'aria-label': 'Why it comes off the roadmap', 'data-rm-reason': b.rid, value: rm[b.rid] || '', onInput: (e) => { rm[b.rid] = e.target.value; } });
109
+ const reasonBox = el('div', { class: 'wform', 'data-rm-off': b.rid }, reason, el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'The reason is kept in the record. Slide below to take it off.'));
110
+ reasonBox.hidden = (V.pick && V.pick[key]) !== 'off';
111
+ const onConfirm = async (o) => {
112
+ if (o.id === 'pf') return moveBet(V, b, COLS[i - 1], 'Moved to ' + CL[COLS[i - 1]] + '. The Calendar and Plan follow.');
113
+ if (o.id === 'pb') return moveBet(V, b, COLS[i + 1], 'Moved to ' + CL[COLS[i + 1]] + '.');
114
+ if (o.id === 'off') {
115
+ if (!(rm[b.rid] || '').trim()) { V.err = 'Say why it comes off: the reason is kept.'; V.errAt = b.rid; V.render(); return; }
116
+ return press(V, '/api/roadmap-ops/bet/take-off', { id: b.rid, reason: rm[b.rid].trim() }, 'Taken off the roadmap. The record stays.', b.rid);
117
+ }
118
+ if (o.id.startsWith('x:')) { const x = (b.options || []).find((q) => 'x:' + q.id === o.id); return markDone(V, key, (x && x.msg) || 'Recorded.'); }
119
+ return markDone(V, key, 'Kept where it is. No change.');
120
+ };
121
+ const mine = V.proposal && V.proposal.move && V.proposal.move.id === b.rid ? V.proposal : null;
122
+ const node = el('div', { 'data-rm-case': b.rid, onClick: (e) => e.stopPropagation() },
123
+ V.err && V.errAt === b.rid ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), V.err) : null,
124
+ mine ? swapProposal(V, b, mine) : null,
125
+ caseNode(V, key, model, { onConfirm }),
126
+ reasonBox);
127
+ // the reason box shows only while "Take it off" is the picked option
128
+ node.addEventListener('click', () => { reasonBox.hidden = (V.pick && V.pick[key]) !== 'off'; });
129
+ return node;
130
+ }
131
+
132
+ /** The swap the route proposed: nothing has moved. Its own slide, bound to what the PM sees (the move and the bets sent back), applies it. */
133
+ function swapProposal(V, b, mine) {
134
+ const sendBack = mine.send_back.map((s) => '“' + s.title + '”' + (s.confidence == null ? '' : ' (' + Math.round(s.confidence * 100) + '%)') + ' to ' + CL[s.to]).join(', then ');
135
+ const host = el('div', { class: 'c3slide' });
136
+ const box = el('div', { class: 'fold w', role: 'status', 'data-rm-proposal': b.rid }, c3ic('scale', 'sm'),
137
+ el('span', null, 'Proposal, nothing moved yet: ' + CL[mine.move.to] + ' is full. To make room, send back ' + sendBack + '.'),
138
+ host,
139
+ el('div', { class: 'chips2' }, el('button', { type: 'button', class: 'pill', onClick: () => { V.proposal = null; V.render(); } }, c3ic('x', 'sm'), 'Leave it')));
140
+ mountSlide(host, { label: 'Slide to swap and move', hash: b.rid + '|' + mine.move.to + '|' + mine.send_back.map((s) => s.id).join(','),
141
+ onConfirm: () => press(V, '/api/roadmap-ops/bet/move', { id: b.rid, to: mine.move.to, confirmSwap: true }, 'Swapped and moved to ' + CL[mine.move.to] + '.', b.rid) });
142
+ return box;
143
+ }
144
+
145
+ /** A repository row (no bet records yet): read-only, from ctx.rows. */
146
+ function repoRow(V, r) {
147
+ return el('li', { class: 'nrow', 'data-rm-repo': r.id },
148
+ el('span', { class: 'ki' }, c3ic(CI[r.horizon] || 'init')),
149
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, r.title || r.id),
150
+ el('div', { class: 'chips' }, r.anchor ? el('span', { class: 'chip2' }, c3ic('flag', 'sm'), short(r.anchor, 26)) : el('span', { class: 'st bad' }, c3ic('alert', 'sm'), 'No anchor'),
151
+ r.health && r.health.level && r.health.level !== 'on' ? el('span', { class: 'st warn' }, c3ic('alert', 'sm'), String(r.health.level).replace('-', ' ')) : null,
152
+ el('span', { class: 'tag' }, 'repository'))));
153
+ }
154
+
155
+ /* ── the add form ── */
156
+ function addForm(V) {
157
+ const title = el('input', { type: 'text', placeholder: 'What is the bet?', 'aria-label': 'What is the bet', 'data-rm-new': 'title' });
158
+ const krs = (V.data.rd && V.data.rd.krs) || [];
159
+ const dl = el('datalist', { id: 'c3-rm-anchors' }, krs.map((k) => el('option', { value: k.id }, k.title)));
160
+ const anchor = el('input', { type: 'text', list: 'c3-rm-anchors', placeholder: 'Anchor: a KR id or health:<id>', 'aria-label': 'Anchor', 'data-rm-new': 'anchor' });
161
+ const col = el('select', { 'aria-label': 'Column', 'data-rm-new': 'horizon' }, el('option', { value: 'later' }, 'Later'), el('option', { value: 'next' }, 'Next'), el('option', { value: 'now' }, 'Now'));
162
+ const scoped = V.scope ? (V.ctx.rows || []).find((r) => r.id === V.scope) : null;
163
+ return el('div', { class: 'wform', 'data-rm-form': '' }, el('b', { style: 'font-size:var(--fs-3)' }, 'Add a bet'), title, anchor, dl, col,
164
+ el('div', { class: 'chips2' },
165
+ el('button', { type: 'button', class: 'pill', 'data-rm-save': '', onClick: () => {
166
+ if (!title.value.trim()) { V.err = 'Name the bet first.'; V.errAt = null; V.render(); return; }
167
+ const body = { title: title.value.trim(), anchor: anchor.value.trim(), horizon: col.value, ...(scoped ? { initiative: scoped.id } : {}) };
168
+ V.form = false; press(V, '/api/roadmap-ops/bet/add', body, 'Added to ' + CL[body.horizon] + '.', null);
169
+ } }, c3ic('check', 'sm'), 'Add it'),
170
+ el('button', { type: 'button', class: 'pill', onClick: () => { V.form = false; V.err = ''; V.render(); } }, c3ic('x', 'sm'), 'Cancel')));
171
+ }
172
+
173
+ /* ── By quarter: the mockup's roadSVG ── */
174
+ function roadSVG(V, L) {
175
+ const W = 560; const LW = 150; const QW = (W - LW) / 4; const rh = 34; const top = 26; const H = top + L.length * rh + 8; const QL = quarters();
176
+ const s = document.createElementNS(NSV, 'svg');
177
+ const add = (tag, a, txt) => { const n = document.createElementNS(NSV, tag); for (const k in a) n.setAttribute(k, a[k]); if (txt != null) n.textContent = txt; s.appendChild(n); return n; };
178
+ for (const [k, v] of Object.entries({ class: 'pg', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': 'Roadmap by quarter. Fainter bars are less certain.' })) s.setAttribute(k, v);
179
+ QL.forEach((q, i) => { add('line', { x1: LW + i * QW, x2: LW + i * QW, y1: 18, y2: H - 4, stroke: 'var(--border)' }); add('text', { x: LW + i * QW + QW / 2, y: 14, 'text-anchor': 'middle' }, q); });
180
+ add('rect', { x: LW + 3, y: 20, width: 6, height: H - 24, fill: 'var(--acc)', rx: 3 });
181
+ L.forEach((b, i) => {
182
+ const y = top + i * rh + rh / 2; const conf = typeof b.confidence === 'number' ? b.confidence : 0.5; const risk = riskOf(V, b);
183
+ const col = b.horizon === 'now' && risk ? (risk.c === 'bad' ? 'var(--destructive)' : 'var(--warning)') : 'var(--acc)';
184
+ const xs = LW + q0Of(b) * QW + 4; const xe = LW + (q1Of(b) + 1) * QW - 4;
185
+ add('text', { x: 0, y: y + 4, class: 't' }, short(b.title, 19));
186
+ add('rect', { x: xs, y: y - 8, width: xe - xs, height: 16, rx: 8, fill: col, 'fill-opacity': (0.15 + conf * 0.65).toFixed(2), ...(conf < 0.4 ? { stroke: col, 'stroke-dasharray': '4 3' } : {}) });
187
+ if (!b.parent_kr) add('text', { x: xs + 8, y: y + 4, style: 'fill:var(--destructive)' }, 'no anchor'); else add('text', { x: xs + 8, y: y + 4, style: 'fill:var(--foreground)' }, Math.round(conf * 100) + '%');
188
+ });
189
+ return s;
190
+ }
191
+
192
+ /* ── On your Calendar: dated items from the forecast of the Now initiatives ── */
193
+ function calendarCard(V) {
194
+ const dated = ((V.data.rd && V.data.rd.dated) || []).slice(); // optional (demo): the dated rows as the mockup lists them; real mode derives them from the forecast
195
+ for (const [id, j] of dated.length ? [] : V.data.fc || []) {
196
+ if (V.scope && id !== V.scope) continue;
197
+ const after = new Set((j.items || []).flatMap((x) => x.after || []));
198
+ for (const x of j.items || []) {
199
+ if (x.state === 'done') continue;
200
+ const f = x.forecast || {}; const late = f.status && f.status !== 'on time';
201
+ if (late || !after.has(x.id)) dated.push({ t: x.title, fe: f.end || x.due, plan: x.due, bad: !!late && f.status !== 'at risk' });
202
+ }
203
+ }
204
+ dated.sort((a, b) => String(a.fe).localeCompare(String(b.fe)));
205
+ return card('cal', 'On your Calendar', 'dates that reach it',
206
+ el('ul', { class: 'chgl' }, dated.length ? dated.slice(0, 6).map((d) => el('li', { class: d.bad ? 'bad' : 'ok' }, c3ic(d.bad ? 'clock' : 'check', 'sm'), el('span', null, d.t + ' · ' + dayLabel(d.fe) + (d.bad ? ' (plan ' + dayLabel(d.plan) + ')' : '')))) : [el('li', null, c3ic('check', 'sm'), el('span', null, 'Nothing dated.'))]),
207
+ el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Milestones and late dates appear in Calendar week and month. Meetings carry their initiative back here.'));
208
+ }
209
+
210
+ function render(V) {
211
+ V.more = V.more || {};
212
+ const rd = V.data.rd; const bets = betsIn(V); const hasBets = !!(rd && (rd.bets || []).some((b) => !b.off));
213
+ const byCol = (c) => bets.filter((b) => b.horizon === c).sort((a, z) => (a.rank || 0) - (z.rank || 0));
214
+ const nowN = byCol('now').length; const nextN = byCol('next').length;
215
+ const risky = byCol('now').find((b) => riskOf(V, b) && riskOf(V, b).c === 'bad') || byCol('now').find((b) => riskOf(V, b));
216
+ const repoRows = (V.ctx.rows || []).filter((r) => !V.scope || r.id === V.scope);
217
+ const total = hasBets ? bets.length : repoRows.length;
218
+ const head = !rd ? { icon: 'flag', h: V.data.err || 'Reading the roadmap', s: V.data.err ? 'The bets are read from the repository records.' : 'The bets and their capacity are being read.', ring: [0, LIMIT] }
219
+ : { icon: 'flag', h: hasBets ? nowN + ' bet' + (nowN === 1 ? '' : 's') + ' Now, room for ' + Math.max(0, LIMIT - nowN) + ' more' + (risky ? '. ' + risky.title + ' is the risk' : '') : 'No bets on the roadmap yet',
220
+ s: hasBets ? 'Move items on evidence and capacity. Each one names its goal.' : 'Add one with the plus. A bet needs a goal: a KR or a health budget. The initiatives below are the repository\'s own roadmap.', ring: [nowN, LIMIT] };
221
+
222
+ const intro = el('section', { class: 'card sect', 'data-rm-intro': '' },
223
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('flag', 'sm'), 'Roadmap ', el('span', { class: 'bdg g' }, String(total))), el('span', { class: 'chips2' }, c3ib('Add a bet', 'plus', { 'data-a': 'rmform', 'data-rm-add': '', 'aria-expanded': String(!!V.form), onClick: () => { V.form = !V.form; V.err = ''; V.render(); } }, 'sm'))),
224
+ V.form ? addForm(V) : null,
225
+ el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Tap a bet to see where it can move and what each move costs. Swipe a row right to take the agent\u2019s suggestion, left to push it back. Every move checks the anchor and the capacity first.'),
226
+ V.note ? el('div', { class: 'dim mono', style: 'font-size:var(--fs-2)', role: 'status', 'data-rm-note': '' }, V.note) : null,
227
+ V.err && !V.errAt ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), V.err) : null);
228
+
229
+ const cols = COLS.map((c) => {
230
+ const all = hasBets ? byCol(c).map((b) => betRow(V, b)) : repoRows.filter((r) => r.horizon === c).map((r) => repoRow(V, r));
231
+ const rows = V.more[c] ? all : all.slice(0, 15); // a repository has hundreds of initiatives: the first 15, then "Show all"
232
+ if (all.length > rows.length) rows.push(el('li', { class: 'fold' }, el('button', { type: 'button', class: 'pill', 'data-rm-showall': c, onClick: () => { V.more[c] = true; V.render(); } }, 'Show all ' + all.length)));
233
+ return el('section', { class: 'card sect rmcol', 'data-rm-col': c },
234
+ el('div', { class: 'sh2' }, el('h2', null, c3ic(CI[c], 'sm'), CL[c] + ' ', el('span', { class: 'bdg g' }, String(all.length))), el('span', { class: 'dim', style: 'font-size:var(--fs-2)' }, CS[c])),
235
+ el('ul', { class: 'list' }, rows.length ? rows : [emptyRow('check', 'Nothing here.')]));
236
+ });
237
+
238
+ const right = [
239
+ card('flag', 'By quarter', 'fainter means less certain', bets.length ? roadSVG(V, bets) : el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'No bets to chart yet.')),
240
+ card('user', 'Capacity', 'bets in flight', hBars([['Now', nowN / LIMIT, 'var(--acc)', nowN + ' of ' + LIMIT], ['Next', nextN / LIMIT, 'var(--purple)', nextN + ' of ' + LIMIT]]),
241
+ rd && rd.capacity && rd.capacity.over ? el('div', { class: 'fold w' }, c3ic('alert', 'sm'), 'A column holds more than ' + LIMIT + ' bets.') : null),
242
+ calendarCard(V),
243
+ ];
244
+ frame(V, { head, left: [intro, ...cols], right });
245
+ }
246
+
247
+ const view = makeView('roadmap', { init: (V) => load(V), render });
248
+ export const mount = view.mount;
249
+ export const dispose = view.dispose;
@@ -0,0 +1,107 @@
1
+ /* pm/c3-sheet.js — the console3 sheet, as a primitive (console3.html: bsHtml line 2502, the .sheet rules 143 to 145, 180, 496 to 502,
2
+ * the Esc handler 2536 and the phone drag 2538 to 2540).
3
+ *
4
+ * openSheet({ title, icon, body, wide, dw, scrim, onClose }) -> { close, root, sheet, body }
5
+ * closeSheet() closes the open sheet, if any
6
+ *
7
+ * On a desktop window the sheet is the right-hand panel (340px; `wide` 600px; `dw` the document sheet, up to 780px) over a
8
+ * scrim; at 640px or less (`.c3.ph`, the page's own phone switch) it is a bottom sheet with a grab bar that slides up (.3s) and
9
+ * can be dragged down: past 90px it closes, otherwise it springs back. Markup, as the mockup draws it:
10
+ * div.c3.c3e.c3pop > div.scrim + div.sheet.bs[role=dialog][aria-modal] > div.grab + div.sh2(span.chips2 icon+b title, span.chips2 close .ib.sm)
11
+ * + div.sb > div.brief.sbb > body
12
+ * Behaviour: one sheet at a time (opening a second closes the first and runs its onClose); Esc closes (capture phase, so a card
13
+ * under the sheet does not also flip back); a press on the scrim closes; Tab and Shift+Tab stay inside the sheet; focus moves to
14
+ * the sheet on open and returns to the control that opened it on close. `body` is a Node, an array of Nodes, or a function that
15
+ * returns one. Real data is passed as DOM built with el(); nothing here sets innerHTML.
16
+ * Pure parts (Node-testable): trapNext, dragDismiss, bodyNodes, DRAG_CLOSE.
17
+ */
18
+ import { el } from './dom.js';
19
+ import { ensureC3Css, c3ic, c3Phone } from './pm-work-view.js';
20
+
21
+ export const DRAG_CLOSE = 90;
22
+
23
+ /** Where Tab goes inside a trap of `count` focusable controls: index of the next one, wrapping. `at` = -1 when the focus is outside. */
24
+ export function trapNext(count, at, shift) {
25
+ if (count <= 0) return -1;
26
+ if (at < 0) return shift ? count - 1 : 0;
27
+ return shift ? (at - 1 + count) % count : (at + 1) % count;
28
+ }
29
+
30
+ /** A downward drag of dy pixels on the grab bar or the header: close past the threshold, else spring back. */
31
+ export const dragDismiss = (dy) => dy > DRAG_CLOSE;
32
+
33
+ export function bodyNodes(body) {
34
+ const b = typeof body === 'function' ? body() : body;
35
+ if (b == null) return [];
36
+ return Array.isArray(b) ? b.filter(Boolean) : [b];
37
+ }
38
+
39
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
40
+ let current = null; // { root, onKey, stopPhone, close, opener, onClose }
41
+
42
+ export function closeSheet() {
43
+ if (!current) return;
44
+ const c = current; current = null;
45
+ document.removeEventListener('keydown', c.onKey, true);
46
+ if (c.stopPhone) c.stopPhone();
47
+ c.root.remove();
48
+ const back = c.opener;
49
+ if (back && typeof back.focus === 'function' && document.contains(back)) { try { back.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
50
+ if (typeof c.onClose === 'function') { try { c.onClose(); } catch { /* the caller reports its own errors */ } }
51
+ }
52
+
53
+ export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = false, scrim = true, onClose } = {}) {
54
+ closeSheet();
55
+ ensureC3Css();
56
+ const opener = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
57
+ const root = el('div', { class: 'c3 c3e c3pop', 'data-shell': 'sheet' });
58
+ const stopPhone = c3Phone(root);
59
+ const close = () => { if (current && current.root === root) closeSheet(); };
60
+ const sbb = el('div', { class: 'brief sbb' }, ...bodyNodes(body));
61
+ const closeBtn = el('button', { type: 'button', class: 'ib sm', 'aria-label': 'Close', title: 'Close (Esc)', onclick: close }, c3ic('x', 'sm'));
62
+ const grab = el('div', { class: 'grab' });
63
+ const head = el('div', { class: 'sh2' }, el('span', { class: 'chips2' }, c3ic(icon), el('b', null, title)), el('span', { class: 'chips2' }, closeBtn));
64
+ const sheet = el('div', { class: 'sheet bs' + (wide ? ' wide' : '') + (dw ? ' dw' : ''), role: 'dialog', 'aria-modal': String(!!scrim), 'aria-label': title, tabindex: '-1' },
65
+ grab, head, el('div', { class: 'sb' }, sbb));
66
+ /* the mockup's person and playbooks sheets have no scrim (they are panels beside the page, closed by their own button or Esc): `scrim: false` */
67
+ root.append(...(scrim ? [el('div', { class: 'scrim', onclick: close })] : []), sheet);
68
+
69
+ /* the phone drag (mockup 2538 to 2540): only on a bottom sheet, only from the grab bar or the header, never from a button */
70
+ let drag = null;
71
+ const phone = () => root.classList.contains('ph');
72
+ sheet.addEventListener('pointerdown', (e) => {
73
+ if (!phone() || e.button > 0) return;
74
+ const h = e.target.closest && e.target.closest('.grab,.sh2');
75
+ if (!h || e.target.closest('button')) return;
76
+ drag = { y: e.clientY, dy: 0 };
77
+ try { sheet.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
78
+ });
79
+ sheet.addEventListener('pointermove', (e) => {
80
+ if (!drag) return;
81
+ drag.dy = Math.max(0, e.clientY - drag.y);
82
+ sheet.classList.add('dragging'); sheet.style.transform = 'translateY(' + drag.dy + 'px)';
83
+ });
84
+ const endDrag = () => {
85
+ if (!drag) return;
86
+ const d = drag; drag = null;
87
+ sheet.classList.remove('dragging');
88
+ if (dragDismiss(d.dy)) close(); else sheet.style.transform = '';
89
+ };
90
+ sheet.addEventListener('pointerup', endDrag);
91
+ sheet.addEventListener('pointercancel', () => { if (drag) { drag = null; sheet.classList.remove('dragging'); sheet.style.transform = ''; } });
92
+
93
+ const onKey = (e) => {
94
+ if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(); return; }
95
+ if (e.key !== 'Tab') return;
96
+ const items = [...sheet.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetWidth > 0 || n.offsetHeight > 0 || n === document.activeElement);
97
+ const at = items.indexOf(document.activeElement);
98
+ const ni = trapNext(items.length, at, e.shiftKey);
99
+ if (ni < 0) { e.preventDefault(); sheet.focus(); return; }
100
+ if (at < 0 || (e.shiftKey && at === 0) || (!e.shiftKey && at === items.length - 1)) { e.preventDefault(); items[ni].focus(); }
101
+ };
102
+ document.addEventListener('keydown', onKey, true);
103
+ document.body.appendChild(root);
104
+ current = { root, onKey, stopPhone, close, opener, onClose };
105
+ sheet.focus({ preventScroll: true });
106
+ return { close, root, sheet, body: sbb };
107
+ }