create-pmos 0.52.0 → 0.53.1

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 (229) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/README.md +13 -0
  4. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
  6. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/plan-items-stage.js +271 -0
  11. package/payload/scripts/pmos-log.sh +18 -7
  12. package/payload/scripts/workflow-state.js +28 -2
  13. package/payload/state/discoveries.md +1 -1
  14. package/payload/state/log.md +1 -1
  15. package/workspace/README.md +2 -0
  16. package/workspace/REMOTE.md +24 -63
  17. package/workspace/bin/pmos.js +19 -3
  18. package/workspace/cut-stub.js +40 -0
  19. package/workspace/first-paint-audit.js +145 -0
  20. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  21. package/workspace/fixtures/initiatives-api/list.json +176 -0
  22. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  23. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  24. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  25. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  26. package/workspace/fixtures/report-api/draft.json +177 -0
  27. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  28. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  29. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  30. package/workspace/guide/board.md +81 -0
  31. package/workspace/guide/breakdown.md +154 -0
  32. package/workspace/guide/command-palette.md +75 -0
  33. package/workspace/guide/index.md +28 -17
  34. package/workspace/guide/initiatives-roadmap.md +130 -0
  35. package/workspace/guide/initiatives-screen.md +89 -0
  36. package/workspace/guide/initiatives-timeline.md +57 -0
  37. package/workspace/guide/integrated-work.md +94 -0
  38. package/workspace/guide/knowledge-graph.md +3 -68
  39. package/workspace/guide/launcher.md +51 -0
  40. package/workspace/guide/layout.md +102 -32
  41. package/workspace/guide/live-notes.md +3 -3
  42. package/workspace/guide/pm-only-acts.md +89 -0
  43. package/workspace/guide/pm-only-lines.md +61 -0
  44. package/workspace/guide/proactive-engine.md +21 -5
  45. package/workspace/guide/reports-page.md +80 -0
  46. package/workspace/guide/reports.md +73 -0
  47. package/workspace/guide/timeline.md +70 -0
  48. package/workspace/guide/today.md +85 -0
  49. package/workspace/guide/validate-with-typesafe.md +1 -1
  50. package/workspace/lib/actor.js +91 -0
  51. package/workspace/lib/board-cache.js +32 -2
  52. package/workspace/lib/board-cli.js +108 -0
  53. package/workspace/lib/board-fold.js +232 -0
  54. package/workspace/lib/breakdown-export.js +75 -0
  55. package/workspace/lib/brief.js +20 -2
  56. package/workspace/lib/capture.js +98 -0
  57. package/workspace/lib/engine/plan-items-stage.js +271 -0
  58. package/workspace/lib/engine/workflow-state.js +28 -2
  59. package/workspace/lib/factory-join.js +158 -0
  60. package/workspace/lib/goals.js +165 -0
  61. package/workspace/lib/grant-mint.js +113 -0
  62. package/workspace/lib/health-rules-factory.js +92 -0
  63. package/workspace/lib/health-rules-plan.js +164 -0
  64. package/workspace/lib/health.js +79 -0
  65. package/workspace/lib/initiative-cli.js +222 -0
  66. package/workspace/lib/initiative-display.js +183 -0
  67. package/workspace/lib/initiative-needs.js +127 -0
  68. package/workspace/lib/initiative-rows.js +210 -0
  69. package/workspace/lib/initiative-writer.js +141 -0
  70. package/workspace/lib/issues.js +206 -0
  71. package/workspace/lib/item-cli.js +195 -0
  72. package/workspace/lib/item-spans.js +137 -0
  73. package/workspace/lib/launcher-cli.js +207 -0
  74. package/workspace/lib/measure-cli.js +100 -0
  75. package/workspace/lib/measurements.js +189 -0
  76. package/workspace/lib/mobile-nav.js +57 -53
  77. package/workspace/lib/needs-providers.js +120 -0
  78. package/workspace/lib/placements.js +440 -0
  79. package/workspace/lib/plan-items.js +922 -0
  80. package/workspace/lib/plan-ledgers.js +85 -0
  81. package/workspace/lib/plan-rules.js +72 -0
  82. package/workspace/lib/plan-tree.js +380 -0
  83. package/workspace/lib/pm-guard.js +174 -0
  84. package/workspace/lib/pm-lines-needs.js +331 -0
  85. package/workspace/lib/pmos-verbs.js +69 -0
  86. package/workspace/lib/proactive-cli.js +58 -2
  87. package/workspace/lib/proactive.js +9 -6
  88. package/workspace/lib/qr.js +293 -0
  89. package/workspace/lib/remote-access.js +93 -1
  90. package/workspace/lib/report-cli.js +152 -0
  91. package/workspace/lib/report-draft.js +578 -0
  92. package/workspace/lib/report-needs.js +213 -0
  93. package/workspace/lib/route-modules.js +128 -0
  94. package/workspace/lib/routes-board.js +91 -0
  95. package/workspace/lib/routes-capture.js +52 -0
  96. package/workspace/lib/routes-initiatives.js +371 -0
  97. package/workspace/lib/routes-reports.js +163 -0
  98. package/workspace/lib/routes-timeline.js +197 -0
  99. package/workspace/lib/routes-today.js +32 -0
  100. package/workspace/lib/today-cli.js +99 -0
  101. package/workspace/lib/today.js +333 -0
  102. package/workspace/lib/triage.js +163 -0
  103. package/workspace/lib/verify-work-cli.js +81 -0
  104. package/workspace/lib/work-check-needs.js +99 -0
  105. package/workspace/package-lock.json +1 -1
  106. package/workspace/package.json +4 -5
  107. package/workspace/public/icons/apple-touch-icon.png +0 -0
  108. package/workspace/public/icons/icon-192.png +0 -0
  109. package/workspace/public/icons/icon-512.png +0 -0
  110. package/workspace/public/index.html +40 -19
  111. package/workspace/public/pm/app.js +330 -30
  112. package/workspace/public/pm/bd-board.js +113 -0
  113. package/workspace/public/pm/bd-drag.js +59 -0
  114. package/workspace/public/pm/bd-model.js +152 -0
  115. package/workspace/public/pm/bd-timeline.js +233 -0
  116. package/workspace/public/pm/bd-tree.js +244 -0
  117. package/workspace/public/pm/board-copy.js +57 -0
  118. package/workspace/public/pm/breakdown-api.js +58 -0
  119. package/workspace/public/pm/breakdown-copy.js +138 -0
  120. package/workspace/public/pm/components.css +3135 -0
  121. package/workspace/public/pm/cycle-review.js +125 -0
  122. package/workspace/public/pm/data.js +40 -187
  123. package/workspace/public/pm/dest-board.js +438 -0
  124. package/workspace/public/pm/dest-initiatives.js +134 -0
  125. package/workspace/public/pm/dest-reports.js +270 -0
  126. package/workspace/public/pm/dest-settings.js +217 -0
  127. package/workspace/public/pm/dest-today.js +182 -0
  128. package/workspace/public/pm/dialogs.js +88 -0
  129. package/workspace/public/pm/display-word.js +56 -0
  130. package/workspace/public/pm/dock.js +175 -0
  131. package/workspace/public/pm/fixture.js +8 -227
  132. package/workspace/public/pm/frame-copy.js +99 -0
  133. package/workspace/public/pm/handoff-box.js +82 -0
  134. package/workspace/public/pm/http.js +88 -0
  135. package/workspace/public/pm/icons.js +53 -0
  136. package/workspace/public/pm/initiative-list.js +218 -0
  137. package/workspace/public/pm/initiative-sheet.js +261 -0
  138. package/workspace/public/pm/initiatives-api.js +59 -0
  139. package/workspace/public/pm/initiatives-copy.js +215 -0
  140. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  141. package/workspace/public/pm/initiatives-timeline.js +410 -0
  142. package/workspace/public/pm/initiatives-ui.js +123 -0
  143. package/workspace/public/pm/item-sheet.js +251 -0
  144. package/workspace/public/pm/keep-card.js +204 -0
  145. package/workspace/public/pm/lifecycle.js +7 -1
  146. package/workspace/public/pm/mobile.css +49 -20
  147. package/workspace/public/pm/mobile.js +157 -29
  148. package/workspace/public/pm/modes-live.js +7 -883
  149. package/workspace/public/pm/modes.js +32 -1024
  150. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  151. package/workspace/public/pm/next-step-ui.js +3 -2
  152. package/workspace/public/pm/palette-copy.js +51 -0
  153. package/workspace/public/pm/palette.js +312 -0
  154. package/workspace/public/pm/panel-breakdown.js +702 -0
  155. package/workspace/public/pm/panel-cockpit.js +63 -295
  156. package/workspace/public/pm/panel-detail.js +25 -172
  157. package/workspace/public/pm/panel-initiatives.js +1 -19
  158. package/workspace/public/pm/panel-repos.js +31 -81
  159. package/workspace/public/pm/panel-tabs.js +200 -111
  160. package/workspace/public/pm/panel-trees.js +5 -2
  161. package/workspace/public/pm/portfolio-report.js +61 -0
  162. package/workspace/public/pm/primitives.js +656 -0
  163. package/workspace/public/pm/print.css +33 -0
  164. package/workspace/public/pm/provider-cards.js +136 -0
  165. package/workspace/public/pm/registry.js +194 -0
  166. package/workspace/public/pm/release-card.js +110 -0
  167. package/workspace/public/pm/report-view.js +309 -0
  168. package/workspace/public/pm/reports-copy.js +114 -0
  169. package/workspace/public/pm/roadmap.js +271 -0
  170. package/workspace/public/pm/sig-watch.js +41 -0
  171. package/workspace/public/pm/state.js +8 -4
  172. package/workspace/public/pm/status-banner.js +13 -5
  173. package/workspace/public/pm/tab-doc.js +2 -4
  174. package/workspace/public/pm/tab-term.js +12 -2
  175. package/workspace/public/pm/tab-web.js +3 -2
  176. package/workspace/public/pm/timeline-copy.js +48 -0
  177. package/workspace/public/pm/today-cards.js +244 -0
  178. package/workspace/public/pm/today-copy.js +126 -0
  179. package/workspace/public/pm/today-goals.js +172 -0
  180. package/workspace/public/pm/tokens.css +16 -18
  181. package/workspace/public/pm/topbar.js +102 -0
  182. package/workspace/public/pm/triage-card.js +60 -0
  183. package/workspace/public/pm/undo-hold.js +103 -0
  184. package/workspace/public/pm/view-addrepo.js +2 -1
  185. package/workspace/public/pm/view-livenotes.js +3 -1
  186. package/workspace/public/pm/view-today.js +7 -162
  187. package/workspace/public/vendor/Sortable.min.js +2 -0
  188. package/workspace/public/vendor/lucide-sprite.js +79 -0
  189. package/workspace/replaced-tests.json +2877 -0
  190. package/workspace/run-tests.js +56 -5
  191. package/workspace/server.js +446 -40
  192. package/workspace/sheet-answer.js +61 -0
  193. package/workspace/skills/measure.skill +41 -0
  194. package/workspace/skills/morning-brief.skill +15 -9
  195. package/workspace/skills/report-biweekly.skill +23 -25
  196. package/workspace/skills/tracker-refresh.skill +50 -0
  197. package/workspace/test-files.json +21 -3
  198. package/workspace/test-lists/base.json +6 -0
  199. package/workspace/test-lists/run2-a.json +6 -0
  200. package/workspace/test-lists/run2-b.json +13 -0
  201. package/workspace/test-lists/run2-c.json +19 -0
  202. package/workspace/test-lists/run2-d.json +22 -0
  203. package/workspace/test-lists/run3-0.json +5 -0
  204. package/workspace/test-lists/run3-a.json +5 -0
  205. package/workspace/test-lists/run3-b.json +6 -0
  206. package/workspace/test-lists/run3-c.json +15 -0
  207. package/workspace/test-lists/run3-d.json +6 -0
  208. package/workspace/test-lists/run4-a.json +8 -0
  209. package/workspace/test-lists/run4-b.json +6 -0
  210. package/workspace/test-lists/run4-c.json +13 -0
  211. package/workspace/test-lists/run4-d.json +9 -0
  212. package/workspace/test-lists/run5-a.json +6 -0
  213. package/workspace/test-lists/run5-b.json +6 -0
  214. package/workspace/test-lists/run5-c.json +15 -0
  215. package/workspace/test-lists/workspace-launcher.json +5 -0
  216. package/workspace/test-lists/workspace-phone-app.json +5 -0
  217. package/workspace/test-run.js +27 -1
  218. package/workspace/third-party/README.md +44 -0
  219. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  220. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  221. package/workspace/design-parity.js +0 -673
  222. package/workspace/public/pm/tab-task.js +0 -86
  223. package/workspace/public/pm/view-guide.js +0 -141
  224. package/workspace/public/pm/view-knowledge.js +0 -225
  225. package/workspace/public/pm/view-memory.js +0 -122
  226. package/workspace/public/pm/view-portfolio.js +0 -153
  227. package/workspace/public/pm/view-reports.js +0 -190
  228. package/workspace/public/pm/view-scheduled.js +0 -161
  229. package/workspace/public/pm/view-skills.js +0 -216
@@ -0,0 +1,271 @@
1
+ /* pm/roadmap.js — the Roadmap: Now · Next · Later, ranked (pm-os-absorb Run 2, slice 2D; PRD R20 A, R15, R26, R27).
2
+ *
3
+ * Specimen A: Initiatives opens here. Three ranked columns, each a list of cards. Next and Later carry order only, never a
4
+ * date; a Now card shows its title, stage, "n of m", its health reason when it is not on track, and the check-by date. The
5
+ * header reads "Now · 5 KR · 3 health" and counts against the advisory cap.
6
+ *
7
+ * Ways to move a card, all through one write (POST /api/placements, a `placed` line with `after`):
8
+ * - drag the grip: to rank within a column, or into another (Sortable, bd-drag.js: a 150 ms hold on a touch screen);
9
+ * - Alt+ArrowUp / Alt+ArrowDown on the card;
10
+ * - the ⋯ menu, which is the phone's path: To top, Up, Down, Move to Now, Move to Next, Move to Later, Set check-by. The
11
+ * button shows where a touch screen is (any-pointer: coarse); on a desk the same menu opens from a right click or the menu
12
+ * key, so a card carries one control there, and first paint stays inside the budget.
13
+ * Only the PM's press moves a card into Now (R25): the page just posts, and the server's guard answers. A refusal shows in a
14
+ * toast and the card goes back where it was.
15
+ *
16
+ * The data is one GET (roadmap.json). Roadmap watches that GET's `sig` with sig-watch.js, so a placement written from the
17
+ * CLI or another tab shows within seconds, and never while a drag is live (the answer waits until the drop).
18
+ *
19
+ * mountRoadmap(host, env) → { dispose(), reload() }
20
+ * env = { repo, ws, openInitiative(repo, slug), onData?(answer) }
21
+ */
22
+
23
+ import { el } from './dom.js';
24
+ import { card, iconButton, dragHandle, button, menu, empty, toast, SEL } from './primitives.js';
25
+ import { watchSig } from './sig-watch.js';
26
+ import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
27
+ import { COPY, shortDate } from './initiatives-copy.js';
28
+ import { getRoadmap, sigKey, place, placed, leaveNow } from './initiatives-api.js';
29
+ import { healthSignal, say, checkBySheet, onContextMenu } from './initiatives-ui.js';
30
+
31
+ const HORIZONS = ['now', 'next', 'later'];
32
+
33
+ export function mountRoadmap(host, env) {
34
+ const root = el('div', { class: 'in-roadmap', 'data-in-view': 'roadmap' });
35
+ host.replaceChildren(root);
36
+ const drags = dragSet();
37
+ let data = null;
38
+ let error = null;
39
+ let showDormant = false;
40
+ let dragging = false;
41
+ let deferred = null;
42
+ let focusId = null;
43
+ let disposed = false;
44
+ let stop = null;
45
+ let latest = null; // the answer the sig watcher read last, drawn when its sig changed
46
+
47
+ const ids = (h) => ((data && data.horizons && data.horizons[h]) || []).map((r) => r.id);
48
+ const rowOf = (id) => {
49
+ for (const h of HORIZONS) { const r = ((data && data.horizons[h]) || []).find((x) => x.id === id); if (r) return r; }
50
+ return ((data && data.dormant) || []).find((x) => x.id === id) || null;
51
+ };
52
+
53
+ /* ── writes ───────────────────────────────────────────────────────────────────────────────────────────────────── */
54
+
55
+ /** The model after a move, so the card is where the PM put it before the server answers. */
56
+ function applyLocal(id, horizon, after, extra) {
57
+ const row = rowOf(id);
58
+ if (!row || !data) return;
59
+ for (const h of HORIZONS) data.horizons[h] = data.horizons[h].filter((x) => x.id !== id);
60
+ data.dormant = (data.dormant || []).filter((x) => x.id !== id);
61
+ row.horizon = horizon; row.dormant = false;
62
+ if (horizon === 'now') row.confirmed = true;
63
+ if (extra && 'check_by' in extra) row.check_by = extra.check_by;
64
+ const list = data.horizons[horizon];
65
+ const k = after ? list.findIndex((x) => x.id === after) : -1;
66
+ if (!after) list.unshift(row); else if (k === -1) list.push(row); else list.splice(k + 1, 0, row);
67
+ for (const h of HORIZONS) data.horizons[h].forEach((x, i) => { x.rank = i; });
68
+ const now = data.horizons.now;
69
+ data.header = {
70
+ now: { ...data.header.now, count: now.length, kr: now.filter((x) => x.anchor_kind === 'kr').length, health: now.filter((x) => x.anchor_kind === 'health').length },
71
+ next: { count: data.horizons.next.length },
72
+ later: { count: data.horizons.later.length, dormant: data.dormant.length },
73
+ };
74
+ }
75
+
76
+ async function move(id, horizon, after, extra, okText) {
77
+ extra = extra || leaveNow(rowOf(id), horizon); // a card that leaves Now takes its check-by off the ledger too
78
+ applyLocal(id, horizon, after, extra);
79
+ render();
80
+ const out = await place(env.ws, [placed(id, horizon, after, extra)]);
81
+ if (say(out, okText)) { if (out.sig && data) data.sig = out.sig; }
82
+ await reload();
83
+ }
84
+
85
+ const afterOfRank = (h, id) => {
86
+ const list = ids(h);
87
+ const i = list.indexOf(id);
88
+ return { list, i };
89
+ };
90
+ const toTop = (id) => { const r = rowOf(id); if (r) move(id, r.horizon, null, null, COPY.moved.top(r.title)); };
91
+ function up(id) {
92
+ const r = rowOf(id); if (!r || r.dormant) return;
93
+ const { list, i } = afterOfRank(r.horizon, id);
94
+ if (i <= 0) return;
95
+ move(id, r.horizon, i >= 2 ? list[i - 2] : null);
96
+ }
97
+ function down(id) {
98
+ const r = rowOf(id); if (!r || r.dormant) return;
99
+ const { list, i } = afterOfRank(r.horizon, id);
100
+ if (i < 0 || i >= list.length - 1) return;
101
+ move(id, r.horizon, list[i + 1]);
102
+ }
103
+ const toHorizon = (id, h) => { const r = rowOf(id); if (r) move(id, h, null, null, COPY.moved[h](r.title)); };
104
+ function setCheckBy(id) {
105
+ const r = rowOf(id); if (!r) return;
106
+ const { list, i } = afterOfRank(r.horizon, id);
107
+ const after = i > 0 ? list[i - 1] : null;
108
+ checkBySheet({
109
+ title: COPY.menu.setCheckBy, value: r.check_by,
110
+ onSave: async (date) => {
111
+ const out = await place(env.ws, [placed(id, r.horizon, after, { check_by: date })]);
112
+ if (out && out.ok) { toast({ text: COPY.moved.checkBy(r.title, date ? shortDate(date) : ''), timeout: 4000 }); reload(); }
113
+ return out;
114
+ },
115
+ });
116
+ }
117
+
118
+ function openMenu(anchor, id) {
119
+ const r = rowOf(id); if (!r) return;
120
+ const list = ids(r.horizon);
121
+ const i = list.indexOf(id);
122
+ const items = [{ label: COPY.menu.open, onSelect: () => env.openInitiative(env.repo, id) }];
123
+ const order = [];
124
+ if (!r.dormant && i > 0) order.push({ label: COPY.menu.toTop, onSelect: () => toTop(id) }, { label: COPY.menu.up, onSelect: () => up(id) });
125
+ if (!r.dormant && i >= 0 && i < list.length - 1) order.push({ label: COPY.menu.down, onSelect: () => down(id) });
126
+ if (order.length) items.push({ sep: true }, ...order);
127
+ items.push({ sep: true });
128
+ for (const h of HORIZONS) if (r.horizon !== h || r.dormant) items.push({ label: COPY.menu['move' + h[0].toUpperCase() + h.slice(1)], onSelect: () => toHorizon(id, h) });
129
+ if (r.horizon === 'now') items.push({ sep: true }, { label: COPY.menu.setCheckBy, onSelect: () => setCheckBy(id) });
130
+ menu({ anchor, items, ariaLabel: COPY.aria.more(r.title) });
131
+ }
132
+
133
+ /* ── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────── */
134
+
135
+ function cardOf(r, { grip }) {
136
+ const meta = [r.display && r.display.word, r.progress && r.progress.label];
137
+ if (r.horizon === 'now' && !r.confirmed) meta.push(COPY.unconfirmed);
138
+ if (r.horizon === 'now' && r.check_by) meta.push(COPY.checkBy(shortDate(r.check_by)));
139
+ const hs = healthSignal(r.health);
140
+ const more = iconButton({
141
+ icon: 'ellipsis', size: 'sm', ariaLabel: COPY.aria.more(r.title), expanded: false,
142
+ onClick: (e) => { e.stopPropagation(); openMenu(e.currentTarget, r.id); },
143
+ attrs: { class: 'in-more', 'aria-haspopup': 'menu', 'data-in-more': r.id },
144
+ });
145
+ return card({
146
+ title: r.title, meta, signals: hs ? [hs] : [], lead: grip ? [el('span', { class: 'in-grip' }, dragHandle({ label: COPY.aria.reorder(r.title) }))] : [], trail: [more],
147
+ onOpen: () => env.openInitiative(env.repo, r.id), openLabel: COPY.aria.open(r.title),
148
+ attrs: { class: 'in-card', 'data-in-card': r.id, 'data-horizon': r.horizon, 'data-health': r.health ? r.health.level : 'unknown' },
149
+ openAttrs: { 'data-in-open': r.id },
150
+ });
151
+ }
152
+
153
+ function column(h) {
154
+ const rows = data.horizons[h] || [];
155
+ const list = el('div', { class: 'in-list', 'data-in-list': h, role: 'list', 'aria-label': COPY.aria.column(COPY.horizons[h]) }, rows.map((r) => cardOf(r, { grip: true })));
156
+ const kids = [
157
+ el('div', { class: 'in-col__head' }, el('span', { class: 'in-col__name' }, COPY.horizons[h]), el('span', { class: 'in-col__count', 'data-in-count': h }, String(rows.length))),
158
+ ];
159
+ if (!rows.length) kids.push(el('p', { class: 'in-col__empty' }, COPY.emptyColumn[h]));
160
+ kids.push(list);
161
+ if (h === 'later' && data.dormant && data.dormant.length) {
162
+ kids.push(button({
163
+ label: showDormant ? COPY.hideDormant(data.dormant.length) : COPY.dormant(data.dormant.length), variant: 'ghost', size: 'sm',
164
+ icon: showDormant ? 'chevron-down' : 'chevron-right', attrs: { 'data-in-dormant-toggle': '', 'aria-expanded': showDormant ? 'true' : 'false' },
165
+ onClick: () => { showDormant = !showDormant; render(); },
166
+ }));
167
+ if (showDormant) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-dormant': '' }, data.dormant.map((r) => cardOf(r, { grip: false }))));
168
+ }
169
+ return el('section', { class: 'in-col', 'data-in-col': h, 'aria-label': COPY.horizons[h] }, kids);
170
+ }
171
+
172
+ function headLine() {
173
+ const n = data.header.now;
174
+ const over = n.cap && n.count > n.cap;
175
+ return el('div', { class: 'in-nowline' + (over ? ' in-nowline--over' : ''), 'data-in-nowline': '', title: over ? COPY.overCap(n.count, n.cap) : null }, COPY.nowLine(n));
176
+ }
177
+
178
+ function render() {
179
+ if (disposed) return;
180
+ const active = document.activeElement && document.activeElement.getAttribute ? document.activeElement.getAttribute('data-in-open') : null;
181
+ const refocus = focusId || active;
182
+ focusId = null;
183
+ drags.destroy();
184
+ if (!data) {
185
+ root.replaceChildren(error ? el('p', { class: 'in-note in-note--error', role: 'alert' }, COPY.readFailed(error)) : empty({ text: COPY.reading }));
186
+ return;
187
+ }
188
+ const kids = [headLine(), el('p', { class: 'in-note in-hint', 'data-in-hint': '' }, COPY.menuHintCard)];
189
+ if (data.flags && data.flags.length) kids.push(el('p', { class: 'in-note', 'data-in-flags': '' }, COPY.flags(data.flags.length)));
190
+ kids.push(el('div', { class: 'in-cols', 'data-in-cols': '' }, HORIZONS.map(column)));
191
+ root.replaceChildren(...kids);
192
+ if (refocus) { const t = root.querySelector('[data-in-open="' + CSS.escape(refocus) + '"]'); if (t) t.focus({ preventScroll: true }); }
193
+ bindDrag();
194
+ }
195
+
196
+ function bindDrag() {
197
+ loadSortable().then((S) => {
198
+ if (!S || disposed) return;
199
+ drags.destroy();
200
+ for (const list of root.querySelectorAll('.in-list[data-in-list]')) {
201
+ drags.add(new S(list, baseOptions({
202
+ group: 'in-roadmap', handle: SEL.drag, draggable: '.in-card',
203
+ onStart: () => { dragging = true; },
204
+ onEnd: (evt) => {
205
+ dragging = false;
206
+ const id = evt.item && evt.item.getAttribute('data-in-card');
207
+ const to = evt.to && evt.to.getAttribute('data-in-list');
208
+ if (!id || !to) { render(); return; }
209
+ const others = ids(to).filter((x) => x !== id);
210
+ const k = Math.max(0, Math.min(evt.newIndex, others.length));
211
+ const after = k > 0 ? others[k - 1] : null;
212
+ const r = rowOf(id);
213
+ if (r && r.horizon === to && ids(to).indexOf(id) === k) { render(); return; }
214
+ move(id, to, after);
215
+ if (deferred) { const d = deferred; deferred = null; adopt(d); }
216
+ },
217
+ })));
218
+ }
219
+ });
220
+ }
221
+
222
+ /* The card menu is the phone's path (the ⋯ button shows where a touch screen is). On a desk it opens from the context menu:
223
+ * a right click, or the menu key on a focused card. */
224
+ onContextMenu(root, (t) => {
225
+ const node = t && t.closest ? t.closest('[data-in-card]') : null;
226
+ const open = node && node.querySelector('[data-in-open]');
227
+ return open ? { anchor: open, id: node.getAttribute('data-in-card') } : null;
228
+ }, openMenu);
229
+
230
+ /* ── keyboard: Alt+ArrowUp / Alt+ArrowDown on a card reorder it ──────────────────────────────────────────────── */
231
+ root.addEventListener('keydown', (e) => {
232
+ if (!e.altKey || e.ctrlKey || e.metaKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
233
+ const open = e.target && e.target.closest ? e.target.closest('[data-in-open]') : null;
234
+ if (!open) return;
235
+ e.preventDefault(); e.stopPropagation();
236
+ const id = open.getAttribute('data-in-open');
237
+ focusId = id;
238
+ if (e.key === 'ArrowUp') up(id); else down(id);
239
+ });
240
+
241
+ /* ── reading ──────────────────────────────────────────────────────────────────────────────────────────────────── */
242
+
243
+ function adopt(answer) {
244
+ if (disposed) return;
245
+ if (dragging) { deferred = answer; return; }
246
+ data = answer; error = null;
247
+ if (env.onData) env.onData(answer);
248
+ render();
249
+ }
250
+
251
+ let reads = 0; // only the newest read is drawn: an older answer that comes back late is dropped
252
+ async function reload() {
253
+ const mine = ++reads;
254
+ const a = await getRoadmap(env.ws);
255
+ if (disposed || mine !== reads) return;
256
+ if (a && a.ok) adopt(a);
257
+ else if (!data) { error = (a && a.error) || 'the server did not answer'; render(); }
258
+ }
259
+
260
+ render();
261
+ reload().then(() => {
262
+ if (disposed) return;
263
+ stop = watchSig(async () => { const a = await getRoadmap(env.ws); if (a && a.ok) { latest = a; return sigKey(a); } return null; },
264
+ () => { if (latest) adopt(latest); }, { everyMs: 10000 });
265
+ });
266
+
267
+ return {
268
+ reload,
269
+ dispose() { disposed = true; if (stop) stop(); drags.destroy(); },
270
+ };
271
+ }
@@ -0,0 +1,41 @@
1
+ /* pm/sig-watch.js — how a D86 destination learns that its ledgers changed (pm-os-absorb seams §4.9).
2
+ *
3
+ * watchSig(readSig, onChange, { everyMs = 10000 }) → stop()
4
+ *
5
+ * The /api/sessions `ledgers` signatures cover plan-items only (a golden pins that shape), so pmos:ledgers never fires
6
+ * for a placement, a measurement or an issue written from the CLI or another tab. Every destination and extension that
7
+ * shows those (Roadmap 2D, Timeline 4C, Board 4D, Reports 5C, and the Today cards of initiative-needs and report-needs
8
+ * through provider-cards.js) watches its OWN GET `sig` with this one helper:
9
+ * - `readSig()` → Promise<string|null>: the route's current `sig` (a cheap GET; null when unreachable);
10
+ * - it is read on window focus, on visibilitychange to visible, and every `everyMs` while the page is visible;
11
+ * - `onChange(sig)` runs only when the value differs from the last one seen (the first read only records it).
12
+ * No import, no timer while hidden, nothing in fixture mode (the caller does not start it there).
13
+ */
14
+
15
+ export function watchSig(readSig, onChange, { everyMs = 10000 } = {}) {
16
+ let last;
17
+ let seen = false;
18
+ let busy = false;
19
+ let stopped = false;
20
+ const tick = async () => {
21
+ if (stopped || busy || (typeof document !== 'undefined' && document.visibilityState === 'hidden')) return;
22
+ busy = true;
23
+ let sig = null;
24
+ try { sig = await readSig(); } catch { sig = null; }
25
+ busy = false;
26
+ if (stopped || sig == null) return;
27
+ if (!seen) { seen = true; last = sig; return; }
28
+ if (sig !== last) { last = sig; try { onChange(sig); } catch (e) { console.error('[sig-watch]', e); } }
29
+ };
30
+ const onVis = () => { if (document.visibilityState === 'visible') tick(); };
31
+ const timer = setInterval(tick, Math.max(1000, Number(everyMs) || 10000));
32
+ window.addEventListener('focus', tick);
33
+ document.addEventListener('visibilitychange', onVis);
34
+ tick();
35
+ return function stop() {
36
+ stopped = true;
37
+ clearInterval(timer);
38
+ window.removeEventListener('focus', tick);
39
+ document.removeEventListener('visibilitychange', onVis);
40
+ };
41
+ }
@@ -42,7 +42,7 @@ const COCKPIT_START = {
42
42
  tabs: [
43
43
  { id: 'term:ooh-weather-expansion:claude · merge PR 41', kind: 'term', title: 'claude · merge PR 41', slug: 'ooh-weather-expansion', repo: 'microsites', sess: 'claude · merge PR 41', state: 'working', role: 'TERM' },
44
44
  { id: 'doc:planning/prd/ooh-weather-expansion-prd.md', kind: 'doc', title: '…-prd.md', slug: 'ooh-weather-expansion', path: 'planning/prd/ooh-weather-expansion-prd.md', role: 'DOC' },
45
- { id: 'view:memory:microsites', kind: 'view', view: 'memory', title: 'tool · memory', slug: 'microsites', repo: 'microsites' },
45
+ { id: 'view:validate:microsites', kind: 'view', view: 'validate', title: 'tool · validate', slug: 'microsites', repo: 'microsites' },
46
46
  ],
47
47
  active: 'term:ooh-weather-expansion:claude · merge PR 41',
48
48
  };
@@ -56,7 +56,7 @@ function initial() {
56
56
  }
57
57
  function initialBase() {
58
58
  return {
59
- theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, legendOpen: false, settingsOpen: false,
59
+ theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false,
60
60
  product: null, intgSynced: {},
61
61
  addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
62
62
  addLog: [], addedProducts: [], extraKnow: {},
@@ -70,7 +70,6 @@ function initialBase() {
70
70
  // the rail's menu (workspace-cockpit-nav): open or shut, never persisted
71
71
  menuOpen: false,
72
72
  paletteOpen: false, // Run 2: the ⌘K palette — never persisted
73
- layoutFocus: false, // Run 2: the Focus layout — never persisted (distinct from `focus`, the focus-mode data)
74
73
  todayScope: 'repo',
75
74
  filesOpen: false, // Run 3: the Work list shows the repository's files — never persisted
76
75
  todayDismissed: {},
@@ -199,6 +198,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
199
198
  // a view tab's view key (workspace-cockpit-nav) — a reload reopens the same tool
200
199
  'view'];
201
200
  const MAX_TABS = 40;
201
+ const CUT_VIEWS = new Set(['focus', 'portfolio', 'memory', 'knowledge', 'guide', 'skills', 'scheduled', 'reports', 'agent', 'connect']);
202
+ /** A stored tab of a surface that was cut (R29): the Task tab, or a view tab for one of the removed views. */
203
+ const CUT_TABS = (t) => !!t && (t.kind === 'task' || (t.kind === 'view' && CUT_VIEWS.has(t.view)));
202
204
 
203
205
  function packTab(t) {
204
206
  if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
@@ -221,7 +223,6 @@ const PERSIST = {
221
223
  // the dragged width of the navigation column (clamped again by app.js on the way out). `col1` now means "the list under the
222
224
  // repositories is open"; the old second-column width (col1W) is no longer stored and an old value is ignored on load.
223
225
  col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
224
- settingsOpen: isBool,
225
226
  product: isSlug,
226
227
  sel: isSlug,
227
228
  collapsedGroups: isFlagMap,
@@ -267,6 +268,9 @@ function restore(target) {
267
268
  try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
268
269
  if (ok) target[k] = saved[k];
269
270
  }
271
+ /* A tab of a surface the R29 cuts removed (the Task tab, the Focus, Portfolio, Memory, Knowledge, guide, Skills, Scheduled,
272
+ * Agents and Connect views) is dropped on load, so an old record cannot reopen a pane that no longer exists. */
273
+ if (Array.isArray(target.tabs)) target.tabs = target.tabs.filter((t) => !CUT_TABS(t));
270
274
  /* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
271
275
  * list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
272
276
  * pane and the PM would see a window with no tab selected and no way to guess why. */
@@ -12,10 +12,12 @@
12
12
  */
13
13
  import { el } from './dom.js';
14
14
  import { state, setState, FIXTURE } from './state.js';
15
+ import { wordOf } from './display-word.js';
16
+ import { button } from './primitives.js';
15
17
 
16
18
  const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
17
19
  const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
18
- const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
20
+ const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--foreground)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
19
21
  const WHO = { pm: 'you', agent: 'the agent', system: 'nobody — it is derived' };
20
22
  const iv = (t) => el('span', null, t == null ? '' : String(t));
21
23
 
@@ -25,6 +27,13 @@ function line(label, value, attr) {
25
27
  el('span', { 'data-r18': attr, style: 'font-size:12px;line-height:1.5;color:var(--foreground);overflow-wrap:anywhere' }, iv(value)));
26
28
  }
27
29
 
30
+ /** "See everything waiting on you" opens Today: the frame's Today place, or Today as a tab in the old shells. */
31
+ function seeWaiting() {
32
+ const f = typeof window !== 'undefined' && window.pmApp ? window.pmApp.frame : null;
33
+ if (f && f.on) f.go('today');
34
+ else setState({ mode: 'today', col1: true });
35
+ }
36
+
28
37
  /** @returns {HTMLElement|null} null in fixture mode (the prototype has no such block). */
29
38
  export function statusBanner(product, slug, rec) {
30
39
  const sg = SG();
@@ -38,7 +47,8 @@ export function statusBanner(product, slug, rec) {
38
47
  'The status could not be read — ' + (err || 'the server returned no status for this initiative') + '. No group is shown because none can be derived.'));
39
48
  }
40
49
  const key = sg.keyOf(row);
41
- const g = sg.BY_KEY[key];
50
+ // R3: the word is the one every surface reads (display-word.js → lib/initiative-display.js); the group text below is the evidence
51
+ const g = { ...sg.BY_KEY[key], label: wordOf(row, rec) || sg.BY_KEY[key].label };
42
52
  const items = (rec && rec.nextSteps && rec.nextSteps.items) || [];
43
53
  const waiting = items.filter((i) => i.kind === 'needs' || i.kind === 'returned');
44
54
  // The one thing to do next: the engine's first item, unless it is not yours and something is waiting on you.
@@ -66,9 +76,7 @@ export function statusBanner(product, slug, rec) {
66
76
  if (key === 'awaiting-acceptance' || key === 'needs-you') {
67
77
  kids.push(el('div', { 'data-r18': 'acceptance-note', style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground);padding-top:4px' },
68
78
  'Acceptance is yours and cannot be delegated (D12). Tell the session that holds this initiative to record it, or run scripts/record-acceptance.js. The workspace never records one for you.'),
69
- el('button', { 'data-r18': 'see-waiting', onClick: () => setState({ mode: 'focus', settingsOpen: false, col1: true }),
70
- style: 'align-self:flex-start;font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin-top:4px' },
71
- 'see everything waiting on you'));
79
+ button({ label: 'see everything waiting on you', variant: 'outline', size: 'sm', onClick: seeWaiting, attrs: { 'data-r18': 'see-waiting' } }));
72
80
  }
73
81
  return el('div', { 'data-r18': 'status-banner', 'data-key': key, style: 'display:flex;flex-direction:column;gap:2px;padding:8px 14px;border-bottom:1px solid var(--border)' }, kids);
74
82
  }
@@ -43,6 +43,7 @@ import { state, setState, FIXTURE } from './state.js';
43
43
  import * as FX from './fixture.js';
44
44
  import { mdRender } from './markdown.js';
45
45
  import { openDoc } from './panel-initiatives.js';
46
+ import { button } from './primitives.js';
46
47
 
47
48
  /** `{{ expr }}` — one interpolated value in its own span, as the prototype's runtime emits it. */
48
49
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
@@ -738,10 +739,7 @@ export function mountDoc(host, tab) {
738
739
  hover: 'border-color:var(--ring)',
739
740
  }, iv(mm)));
740
741
 
741
- const saveBtn = dirty ? el('button', {
742
- onClick: () => save(tab, text),
743
- style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
744
- }, 'save') : null;
742
+ const saveBtn = dirty ? button({ label: 'save', variant: 'primary', size: 'sm', onClick: () => save(tab, text) }) : null;
745
743
 
746
744
  /* The one control that grants a privilege, and the only place the PM learns the page CAN run.
747
745
  * Offered only where it means something — a document actually being rendered as a page, in the
@@ -47,6 +47,7 @@
47
47
  import { el, setChildren } from './dom.js';
48
48
  import { state, setState, subscribe, FIXTURE } from './state.js';
49
49
  import * as FX from './fixture.js';
50
+ import { pageNonce } from './http.js'; // R25: a session started with an initiative binds only with the nonce
50
51
 
51
52
  /* ── the design's terminal colours ───────────────────────────────────────────────────────────
52
53
  * `--term-bg` is oklch(0.148 0.004 228.8) in BOTH themes. Resolved through a canvas at runtime so
@@ -118,6 +119,7 @@ const LINKS = () => (typeof window !== 'undefined' && window.WebLinksAddon ? win
118
119
  const SOCK = {
119
120
  ws: null, open: false, tries: 0,
120
121
  sessions: [], // the server's session list, module-local on purpose (see below)
122
+ listed: false, // THIS connection's `hello` has arrived, so `sessions` is current — see bind()
121
123
  attached: new Set(), // session ids this connection has attached
122
124
  spawning: new Map(), // tab id → true, while a `new` is in flight
123
125
  spawnQueue: [], // the SAME requests, in order — `created` carries no correlation id
@@ -292,7 +294,8 @@ function connect() {
292
294
  let ws;
293
295
  try {
294
296
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
295
- ws = new WebSocket(`${proto}://${location.host}`);
297
+ const n = pageNonce();
298
+ ws = new WebSocket(`${proto}://${location.host}/` + (n ? '?pmos-nonce=' + encodeURIComponent(n) : ''));
296
299
  } catch { return; }
297
300
  SOCK.ws = ws;
298
301
  ws.onopen = () => {
@@ -305,7 +308,7 @@ function connect() {
305
308
  for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
306
309
  };
307
310
  ws.onclose = () => {
308
- SOCK.open = false; SOCK.ws = null; SOCK.attached.clear();
311
+ SOCK.open = false; SOCK.listed = false; SOCK.ws = null; SOCK.attached.clear();
309
312
  /* An in-flight `new` is abandoned rather than retried: the server may well have started the
310
313
  * session before the socket dropped, and the reconnect's `hello` will find it by name. Retrying
311
314
  * blind is how a PM ends up with two shells and one tab.
@@ -340,6 +343,7 @@ function send(obj) {
340
343
  function handle(m) {
341
344
  if (m.t === 'hello' || m.t === 'sessions') {
342
345
  SOCK.sessions = Array.isArray(m.sessions) ? m.sessions : [];
346
+ SOCK.listed = true;
343
347
  if (m.t === 'hello' && Array.isArray(m.workspaces)) SOCK.workspaces = m.workspaces;
344
348
  if (m.t === 'hello' && m.home) SOCK.home = m.home;
345
349
  for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
@@ -565,6 +569,12 @@ function bind(rec) {
565
569
  return;
566
570
  }
567
571
  if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
572
+ /* NOT BEFORE THE LIST. `onopen` fires one frame before `hello`, and on a fresh page the pool is then empty (or
573
+ * `state.sessions`, which the 4 s poll may not have filled yet), so every name join below answers "not found". A tab
574
+ * that may spawn then starts a SECOND session under a name that is running — the reload case pm-term-test.js
575
+ * asserts. Starting a session or saying one is gone is decided only against the list this connection sent; `hello`
576
+ * is the very next frame and re-binds every pane. An id that IS known attached above, without waiting. */
577
+ if (!SOCK.listed) return;
568
578
  if (SOCK.spawning.get(rec.tab.id)) return;
569
579
  if (wantsSpawn(rec.tab)) {
570
580
  /* THE FALLBACK SPAWN — no longer the PM's path, and no longer pretending to be.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { el, setChildren } from './dom.js';
9
9
  import { state, setState } from './state.js';
10
+ import { askText } from './dialogs.js';
10
11
 
11
12
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
12
13
  const BTN = 'font-family:var(--mono);font-size:10.5px;padding:3px 9px;border:1px solid var(--border);color:var(--muted-foreground);min-height:26px';
@@ -46,8 +47,8 @@ export async function openWeb(raw, opts) {
46
47
  }
47
48
 
48
49
  /** The 🌐 control's action: ask for an address, then open it. */
49
- export function promptWeb() {
50
- const raw = typeof window !== 'undefined' && window.prompt ? window.prompt('Open a web page in a tab — the full address, starting with http or https:', '') : null;
50
+ export async function promptWeb() {
51
+ const raw = await askText({ title: 'Open a web page in a tab', label: 'The full address, starting with http or https.', placeholder: 'https://', confirmLabel: 'Open' });
51
52
  if (raw === null || !String(raw).trim()) return;
52
53
  openWeb(raw);
53
54
  }
@@ -0,0 +1,48 @@
1
+ /* pm/timeline-copy.js — every word the Timelines show (pm-os-absorb Run 4: slice 4B writes it, 4C imports it).
2
+ *
3
+ * Pure data and pure functions. No DOM and no import, so `glossary.test.js` can load this file in Node and check every
4
+ * string against the glossary rules (GLOSSARY below also holds guide/timeline.md to them). A string that a person reads
5
+ * on Breakdown › Timeline is written here and nowhere else. Dates arrive already short ("20 Oct").
6
+ */
7
+
8
+ export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
9
+
10
+ /** "2026-10-20" -> "20 Oct". A date-only value is read as a calendar day, never shifted by the time zone. */
11
+ export function shortDate(ymd) {
12
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(ymd || ''));
13
+ if (!m) return '';
14
+ return Number(m[3]) + ' ' + MONTHS[Number(m[2]) - 1];
15
+ }
16
+
17
+ /** "2026-10" -> "Oct", and "Jan 2027" for a January, so the year shows where it changes. */
18
+ export function monthLabel(ym) {
19
+ const m = /^(\d{4})-(\d{2})/.exec(String(ym || ''));
20
+ if (!m) return '';
21
+ return Number(m[2]) === 1 ? 'Jan ' + m[1] : MONTHS[Number(m[2]) - 1];
22
+ }
23
+
24
+ export const COPY = {
25
+ view: 'Timeline',
26
+ chart: 'Timeline of the items',
27
+ today: 'Today',
28
+ all: 'All items',
29
+ hint: 'Drag a bar to move its dates. Drag the end of a bar to change the due date.',
30
+ noDates: 'No item has a start or a due date. Open an item in the Tree and set its dates. Its bar then shows here.',
31
+ undated: (n) => (Number(n) === 1 ? '1 item has no dates. Set its dates in the item sheet.' : `${n} items have no dates. Set their dates in the item sheet.`),
32
+ range: (from, to) => (!to || from === to ? String(from) : `${from} – ${to}`),
33
+ stageRow: (stage, n) => (Number(n) === 1 ? `${stage}, 1 dated item` : `${stage}, ${n} dated items`),
34
+ /** One stage's part of the first row, in that row's accessible name. */
35
+ stageSpan: (stage, range) => `${stage}: ${range}.`,
36
+ /** The bar's accessible name: what it is, its dates, its health and milestone, and how to move it without a pointer.
37
+ * `notes` are full sentences without the full stop (the health text, `milestone(date)`). */
38
+ bar: (title, range, notes = []) => [`${title}, ${range}.`, ...notes.filter(Boolean).map((n) => n + '.'),
39
+ 'Press to open the item.', 'Use the arrow keys to move the dates by one day.',
40
+ 'Use Shift and an arrow key to change the due date.', 'Press Enter to save.'].join(' '),
41
+ milestone: (date) => `Milestone on ${date}`,
42
+ /** R8 reasons. Each one is the text beside the dot. */
43
+ overlap: (title, date) => `At risk: starts before ${title} ends on ${date}`,
44
+ overdue: (date) => `At risk: due on ${date}, not done`,
45
+ milestoneOverdue: (date) => `Off track: milestone due on ${date}, not done`,
46
+ };
47
+
48
+ export const GLOSSARY = { guide: 'timeline.md' };