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,152 @@
1
+ /* pm/bd-model.js — local changes to a fetched Layout, so a write shows before the server answers (pm-os-absorb Run 1).
2
+ *
3
+ * The server's Layout (GET /api/plan-items) is the truth; these helpers only change a COPY of it in the page, the same
4
+ * way the server's fold would, so that an optimistic write or a held change (undo-hold.js) reads right on screen until
5
+ * the next fetch replaces it. `recount` rebuilds every derived list and count (stage items, progress, the Board columns,
6
+ * the 14-day Done window) from the items map with the server's rules, so the Board column always equals the row state.
7
+ * Nothing here is stored or sent (D33).
8
+ */
9
+
10
+ export const PM = 'pm:wawan';
11
+ const DAY = 86400000;
12
+
13
+ const todayUTC = (now) => new Date(now).toISOString().slice(0, 10);
14
+
15
+ /** Tree order inside a stage: order, then created, then id. */
16
+ export function treeOrder(items) {
17
+ return (a, b) => {
18
+ const x = items[a]; const y = items[b];
19
+ if (x.order !== y.order) return (x.order || 0) - (y.order || 0);
20
+ if ((x.createdAt || '') !== (y.createdAt || '')) return (x.createdAt || '') < (y.createdAt || '') ? -1 : 1;
21
+ return a < b ? -1 : a > b ? 1 : 0;
22
+ };
23
+ }
24
+
25
+ /** Rebuild the stage lists, counts, progress and Board columns from `L.items`. Returns L. */
26
+ export function recount(L) {
27
+ if (!L || !L.items) return L;
28
+ const by = Object.create(null);
29
+ for (const s of L.stages || []) by[s.id] = [];
30
+ const other = [];
31
+ const ids = Object.keys(L.items).filter((id) => L.items[id] && L.items[id].state !== 'withdrawn').sort(treeOrder(L.items));
32
+ for (const id of ids) (by[L.items[id].stage] || other).push(id);
33
+ let done = 0; let total = 0; let proposed = 0;
34
+ const count = (list) => {
35
+ let d = 0; let t = 0; let p = 0;
36
+ for (const id of list) { const it = L.items[id]; if (it.proposed) p++; else { t++; if (it.state === 'done') d++; } }
37
+ return { d, t, p };
38
+ };
39
+ for (const s of L.stages || []) {
40
+ const c = count(by[s.id]);
41
+ s.items = by[s.id]; s.done = c.d; s.total = c.t; s.proposed = c.p;
42
+ s.count = c.t ? `${c.d} of ${c.t}` : null;
43
+ s.visible = c.t > 0 || c.p > 0 || !!s.owed;
44
+ done += c.d; total += c.t; proposed += c.p;
45
+ }
46
+ const oc = count(other);
47
+ done += oc.d; total += oc.t; proposed += oc.p;
48
+ L.other = { items: other };
49
+ L.progress = { done, total, proposed, label: total ? `${done} of ${total}` : proposed ? `no breakdown · ${proposed} proposed` : 'no breakdown' };
50
+ const cut = Date.parse(L.now || new Date().toISOString()) - 14 * DAY;
51
+ const cols = { todo: [], doing: [], review: [], done: [] };
52
+ let older = 0;
53
+ const ordered = [...(L.stages || []).flatMap((s) => s.items), ...other];
54
+ for (const id of ordered) {
55
+ const it = L.items[id];
56
+ if (!cols[it.state]) continue;
57
+ if (it.state === 'done' && it.doneAt && Date.parse(it.doneAt) < cut) { older++; continue; }
58
+ cols[it.state].push(id);
59
+ }
60
+ L.board = { ...cols, doneOlder: older };
61
+ return L;
62
+ }
63
+
64
+ /** The label of a stage id ("Build"), or the id itself for a stage this profile does not have. */
65
+ export function stageLabel(L, id) {
66
+ const s = (L && L.stages || []).find((x) => x.id === id);
67
+ return s ? s.label : (id || '');
68
+ }
69
+
70
+ /** A move to `to`, as the server's fold would record it for a PM move. */
71
+ export function moveLocal(L, id, to) {
72
+ const it = L && L.items[id];
73
+ if (!it) return L;
74
+ const at = new Date().toISOString();
75
+ it.state = to;
76
+ it.doneAt = to === 'done' ? at : null;
77
+ it.noEvidence = false;
78
+ it.override = it.owner === 'agent';
79
+ it.overrideShown = it.override;
80
+ it.movedBy = PM;
81
+ it.late = !!it.due && to !== 'done' && it.due < todayUTC(Date.now());
82
+ it.updatedBy = PM; it.updatedAt = at; it.byLabel = 'you';
83
+ return recount(L);
84
+ }
85
+
86
+ /** A `set` of fields. */
87
+ export function setLocal(L, id, fields) {
88
+ const it = L && L.items[id];
89
+ if (!it) return L;
90
+ Object.assign(it, fields);
91
+ if ('due' in fields) it.late = !!it.due && it.state !== 'done' && it.due < todayUTC(Date.now());
92
+ it.updatedBy = PM; it.updatedAt = new Date().toISOString(); it.byLabel = 'you';
93
+ return recount(L);
94
+ }
95
+
96
+ /** A `flagged` line. */
97
+ export function flagLocal(L, id, blocked, reason) {
98
+ const it = L && L.items[id];
99
+ if (!it) return L;
100
+ it.blocked = blocked ? { reason, by: PM, at: new Date().toISOString() } : null;
101
+ it.updatedBy = PM; it.byLabel = 'you';
102
+ return recount(L);
103
+ }
104
+
105
+ /** A `withdrawn` line: the item leaves every list. */
106
+ export function removeLocal(L, id) {
107
+ if (!L || !L.items[id]) return L;
108
+ delete L.items[id];
109
+ L.withdrawnCount = (L.withdrawnCount || 0) + 1;
110
+ for (const b of L.batches || []) b.items = (b.items || []).filter((x) => x !== id);
111
+ L.batches = (L.batches || []).filter((b) => b.items.length);
112
+ for (const b of L.batches) b.count = b.items.length;
113
+ return recount(L);
114
+ }
115
+
116
+ /** A `kept` line: the batch's items stop being proposed. */
117
+ export function keepLocal(L, by, ids) {
118
+ if (!L) return L;
119
+ for (const id of ids || []) { const it = L.items[id]; if (it && it.proposed) { it.proposed = false; it.kept = true; } }
120
+ L.batches = (L.batches || []).filter((b) => b.by !== by);
121
+ return recount(L);
122
+ }
123
+
124
+ /** A `planned` line drawn before the server has given it an id. */
125
+ export function planLocal(L, { tmpId, title, stage, order }) {
126
+ if (!L) return L;
127
+ const at = new Date().toISOString();
128
+ const max = Math.max(0, ...Object.values(L.items).filter((x) => x.stage === stage).map((x) => x.order || 0));
129
+ L.items[tmpId] = {
130
+ id: tmpId, initiative: L.initiative, title, stage, stageKnown: true, order: Number.isFinite(order) ? order : max + 1024,
131
+ owner: 'you', after: [], start: null, due: null, milestone: false, note: '', state: 'todo', blocked: null, evidence: null,
132
+ noEvidence: false, doneAt: null, movedBy: null, override: false, overrideShown: false, proposed: false, kept: false,
133
+ late: false, createdBy: PM, createdVia: 'ui', createdAt: at, updatedBy: PM, updatedVia: 'ui', updatedAt: at, byLabel: 'you',
134
+ history: [], pending: true,
135
+ };
136
+ return recount(L);
137
+ }
138
+
139
+ /** The order for a row placed between `prev` and `next` (either may be absent). Null when the gap is too small. */
140
+ export function orderBetween(prev, next) {
141
+ if (prev === null && next === null) return 1024;
142
+ if (prev === null) return next - 1024;
143
+ if (next === null) return prev + 1024;
144
+ const mid = (prev + next) / 2;
145
+ return Math.abs(next - prev) < 1e-6 || mid === prev || mid === next ? null : mid;
146
+ }
147
+
148
+ /** The order after every item of a stage. */
149
+ export function endOrder(L, stage) {
150
+ const list = Object.values(L.items).filter((x) => x.stage === stage).map((x) => x.order || 0);
151
+ return (list.length ? Math.max(...list) : 0) + 1024;
152
+ }
@@ -0,0 +1,233 @@
1
+ /* pm/bd-timeline.js — Breakdown › Timeline: an initiative's items on a time axis (pm-os-absorb Run 4, slice 4B; PRD R20,
2
+ * R8, R27).
3
+ *
4
+ * An extension (registry.js EXTENSIONS): loaded at boot, it only registers the view, and panel-breakdown.js shows it next
5
+ * to Tree and Board. Each dated item is one row whose bar runs from its start to its due; the rows are grouped by stage,
6
+ * under a first row for the whole initiative split by stage. A milestone shows a ◆ on its due date and every row carries
7
+ * the line for today. The spans come from lib/item-spans.js (window.PMOSItemSpans), the one engine Initiatives ›
8
+ * Timeline and the report's strip also read, and every bar is primitives.js `timelineBar`.
9
+ *
10
+ * Writes. Dragging a bar moves its dates and dragging its end changes the due date: each gesture is ONE `set` event
11
+ * through the pane's own write (breakdown-api.js postEvents), with the dates the item has (start and due, or only the
12
+ * one it has), and nothing else. No other item moves (no cascade) and nothing is counted in days of effort. A press with
13
+ * no movement opens the item sheet.
14
+ *
15
+ * Health (R8), as text and a dot on its own line under the dates, and on the bar as an outline: an item that starts
16
+ * before a predecessor ends is at risk and says which one; an overdue item is at risk; an overdue milestone is off track.
17
+ * On track shows nothing. A predecessor in another initiative counts only when the Layout carries its dates
18
+ * (`L.externalAfter`, asked of slice 4A in issues/run4-b.md); until then such a clash is not flagged here.
19
+ *
20
+ * Proposed items (an agent's proposals the PM has not kept) have a muted bar and the "Proposed" signal, and they never
21
+ * stretch a stage bar or the first row (item-spans.js).
22
+ *
23
+ * mount(host, ctx) is called on every render of the pane; it rebuilds only when the items it draws changed, and never
24
+ * while a bar is moving. Undated items are counted under the chart: their dates are set in the item sheet. On its first
25
+ * draw the chart scrolls so today shows.
26
+ */
27
+
28
+ import { el } from './dom.js';
29
+ import { registerBreakdownView } from './registry.js';
30
+ import { timelineBar, timelinePct, signal, empty } from './primitives.js';
31
+ import { COPY, shortDate, monthLabel } from './timeline-copy.js';
32
+ import { COPY as BD_COPY } from './breakdown-copy.js';
33
+
34
+ const SPANS = () => (typeof window !== 'undefined' ? window.PMOSItemSpans : null);
35
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
36
+ const views = new WeakMap(); // host -> { key, held, next }
37
+
38
+ /** Today as the server's Layout gives it (the day `late` was derived on), else the viewer's own calendar day. */
39
+ function todayOf(L) {
40
+ const n = L && typeof L.now === 'string' ? L.now.slice(0, 10) : '';
41
+ if (DATE.test(n)) return n;
42
+ const d = new Date();
43
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
44
+ }
45
+
46
+ /** What the chart draws from: a change to any of it rebuilds the chart, anything else (a note, an owner) does not. */
47
+ function keyOf(L, today) {
48
+ const row = (it) => [it.id, it.title, it.stage, it.order, it.start, it.due, it.milestone, it.state, it.late, it.proposed, (it.after || []).join(' ')];
49
+ const items = Object.values((L && L.items) || {}).map(row);
50
+ const ext = Object.values((L && L.externalAfter) || {}).map(row);
51
+ return JSON.stringify([today, (L.stages || []).map((s) => [s.id, s.label]), items, ext]);
52
+ }
53
+
54
+ /** The R8 flag of one item's span, or null on track. */
55
+ function healthOf(span, it, known) {
56
+ if (it && it.late && it.milestone) return { health: 'off', text: COPY.milestoneOverdue(shortDate(it.due)) };
57
+ if (span.overlapsPredecessor && span.overlap) {
58
+ const p = known[span.overlap.id];
59
+ return { health: 'risk', text: COPY.overlap(p ? p.title : span.overlap.id, shortDate(span.overlap.due)) };
60
+ }
61
+ if (it && it.late) return { health: 'risk', text: COPY.overdue(shortDate(it.due)) };
62
+ return null;
63
+ }
64
+
65
+ /** The fields one gesture writes: the dates the item has. A move of an item with only a due date writes only its due. */
66
+ function fieldsFor(it, c) {
67
+ if (c.mode === 'end') return { due: c.due };
68
+ const f = {};
69
+ if (it.start) f.start = c.start;
70
+ if (it.due) f.due = c.due;
71
+ if (!it.start && !it.due) { f.start = c.start; f.due = c.due; }
72
+ return f;
73
+ }
74
+
75
+ function row(kind, nameKids, bar, attrs) {
76
+ return el('div', { class: 'tl-row tl-row--' + kind, ...attrs }, el('div', { class: 'tl-name' }, ...nameKids), el('div', { class: 'tl-track' }, bar));
77
+ }
78
+
79
+ function axisHead(axis, range, today) {
80
+ /* the month labels on the lower line, Today on the line above them: the two never cover each other */
81
+ const ticks = axis.months.map((m) => {
82
+ const t = el('span', { class: 'tl-month', 'data-month': m.month }, monthLabel(m.month));
83
+ t.style.setProperty('--tl-x', timelinePct(range, m.start).toFixed(3) + '%');
84
+ return t;
85
+ });
86
+ if (today >= range.start && today <= range.end) {
87
+ const x = timelinePct(range, today);
88
+ // the label sits to the right of the line, or to its left in the second half, so it stays inside the axis
89
+ const t = el('span', { class: 'tl-today' + (x > 50 ? ' tl-today--end' : ''), 'data-tl-today': today }, COPY.today);
90
+ t.style.setProperty('--tl-x', x.toFixed(3) + '%');
91
+ ticks.push(t);
92
+ }
93
+ return el('div', { class: 'tl-row tl-row--head', 'aria-hidden': 'true' }, el('div', { class: 'tl-name' }), el('div', { class: 'tl-axis' }, ...ticks));
94
+ }
95
+
96
+ function build(host, ctx, V) {
97
+ const S = SPANS();
98
+ const L = ctx.layout || {};
99
+ const items = L.items || {};
100
+ const ext = L.externalAfter && typeof L.externalAfter === 'object' ? L.externalAfter : {};
101
+ const known = { ...ext, ...items };
102
+ const stages = L.stages || [];
103
+ const label = new Map(stages.map((s) => [s.id, s.label || s.id]));
104
+ const today = todayOf(L);
105
+ // a predecessor in another initiative is passed for the flag only: it gets no row here
106
+ const spans = S ? S.spansOf(known, { stages }).filter((s) => Object.prototype.hasOwnProperty.call(items, s.id)) : [];
107
+ const live = Object.values(items).filter((it) => it && it.state !== 'withdrawn');
108
+ const undated = live.filter((it) => !it.start && !it.due).length;
109
+ const note = undated ? el('p', { class: 'tl-note', 'data-tl-undated': String(undated) }, COPY.undated(undated)) : null;
110
+ if (!spans.length) return [empty({ text: COPY.noDates, attrs: { 'data-tl-empty': '' } }), note];
111
+
112
+ const axis = S.axisOf(spans, { today });
113
+ const range = { start: axis.start, end: axis.end };
114
+ const flags = new Map(spans.map((s) => [s.id, healthOf(s, items[s.id], known)]));
115
+ // the first row and the stage rows draw kept items only: a proposal nobody kept stretches nothing
116
+ const ms = (list) => list.filter((s) => s.milestone && !s.proposed).map((s) => ({ date: s.due, health: flags.get(s.id) ? flags.get(s.id).health : null }));
117
+ const rangeText = (g) => COPY.range(shortDate(g.start), shortDate(g.due));
118
+ const stageName = (id) => label.get(id) || id || '';
119
+ const rows = [axisHead(axis, range, today)];
120
+
121
+ /* the whole initiative, split by stage */
122
+ const whole = S.initiativeSpan(spans);
123
+ const parts = S.stageSpans(spans);
124
+ rows.push(row('all', [el('div', { class: 'tl-title' }, COPY.all), whole ? el('div', { class: 'tl-meta' }, rangeText(whole)) : null],
125
+ timelineBar({
126
+ label: [whole ? `${COPY.all}, ${rangeText(whole)}.` : COPY.all + '.', ...parts.map((g) => COPY.stageSpan(stageName(g.stage), rangeText(g)))].join(' '),
127
+ segments: parts.map((g) => ({ stage: g.stage, start: g.start, due: g.due })), milestones: ms(spans), range, today,
128
+ }), { 'data-tl-all': '' }));
129
+
130
+ /* each stage: its own span, then one row per item */
131
+ const drag = (s) => ({
132
+ onCommit: (c) => { const it = items[s.id]; if (it) ctx.setFields(s.id, fieldsFor(it, c)); },
133
+ onOpen: () => ctx.openItem(s.id),
134
+ onStart: () => { V.held = true; ctx.hold(true); },
135
+ onEnd: () => { V.held = false; ctx.hold(false); },
136
+ });
137
+ const groups = [...new Set(spans.map((s) => s.stage))];
138
+ for (const stage of groups) {
139
+ const mine = spans.filter((s) => s.stage === stage);
140
+ const g = parts.find((x) => x.stage === stage);
141
+ const name = stageName(stage);
142
+ rows.push(row('stage', [el('div', { class: 'tl-title' }, COPY.stageRow(name, mine.length))],
143
+ timelineBar({ label: COPY.stageRow(name, mine.length) + (g ? ', ' + rangeText(g) : ''), segments: g ? [{ stage, start: g.start, due: g.due }] : [], milestones: ms(mine), range, today }),
144
+ { 'data-tl-stage': stage || '' }));
145
+ for (const s of mine) {
146
+ const it = items[s.id] || {};
147
+ const f = flags.get(s.id);
148
+ const when = rangeText(s);
149
+ const title = it.title || s.id;
150
+ const signals = [
151
+ f ? signal({ text: f.text, tone: f.health === 'off' ? 'destructive' : 'warning', wrap: true, attrs: { 'data-tl-health': f.health } }) : null,
152
+ s.proposed ? signal({ text: BD_COPY.signals.proposed, attrs: { 'data-tl-proposed': '' } }) : null,
153
+ ].filter(Boolean);
154
+ rows.push(row('item', [
155
+ el('div', { class: 'tl-title' }, title),
156
+ el('div', { class: 'tl-meta' }, when),
157
+ signals.length ? el('div', { class: 'tl-flag' }, ...signals) : null,
158
+ ], timelineBar({
159
+ label: title,
160
+ segments: [{
161
+ stage: s.stage, start: s.start, due: s.due, health: f ? f.health : null, done: it.state === 'done', muted: s.proposed,
162
+ label: COPY.bar(title, when, [f && f.text, s.milestone && COPY.milestone(shortDate(s.due)), s.proposed && BD_COPY.signals.proposed]),
163
+ }],
164
+ milestones: s.milestone ? [{ date: s.due, health: f ? f.health : null }] : [], range, today, drag: drag(s),
165
+ attrs: { 'data-tl-item': s.id },
166
+ }), { 'data-tl-row': s.id }));
167
+ }
168
+ }
169
+ const chart = el('div', { class: 'tl-chart', role: 'group', 'aria-label': COPY.chart, 'data-tl-range': range.start + '/' + range.end }, ...rows);
170
+ return [el('p', { class: 'tl-hint' }, COPY.hint), el('div', { class: 'tl-scroll', 'data-tl-scroll': '' }, chart), note];
171
+ }
172
+
173
+ /** Scroll the chart so the Today label shows just right of the name column (the first draw only). */
174
+ function scrollToToday(host) {
175
+ const sc = host.querySelector('[data-tl-scroll]');
176
+ const t = host.querySelector('[data-tl-today]');
177
+ const name = host.querySelector('.tl-row--item .tl-name') || host.querySelector('.tl-name');
178
+ if (!sc || !t || !name || sc.scrollWidth <= sc.clientWidth) return;
179
+ const x = t.getBoundingClientRect().left - sc.getBoundingClientRect().left + sc.scrollLeft;
180
+ sc.scrollLeft = Math.max(0, x - name.getBoundingClientRect().width - 48);
181
+ }
182
+
183
+ function mount(host, ctx) {
184
+ let V = views.get(host);
185
+ if (!V) {
186
+ V = { key: null, held: false, next: null };
187
+ views.set(host, V);
188
+ /* A move from the keyboard saves when the bar loses the focus, and that write draws the chart again inside the blur,
189
+ * so the element the focus was going to is gone. Remember which bar it was (blur does not bubble: listen in the
190
+ * capture phase, before the bar's own listener saves) and forget it once the blur is over. */
191
+ host.addEventListener('blur', (e) => {
192
+ const n = e.relatedTarget && host.contains(e.relatedTarget) && e.relatedTarget.closest ? e.relatedTarget.closest('[data-tl-item]') : null;
193
+ V.next = n ? n.getAttribute('data-tl-item') : null;
194
+ }, true);
195
+ host.addEventListener('focusout', () => { V.next = null; });
196
+ }
197
+ if (V.held) return; // never rebuild under a moving bar; the pane draws again when the gesture ends
198
+ const L = ctx.layout || {};
199
+ const key = keyOf(L, todayOf(L));
200
+ if (key === V.key && host.firstChild) return;
201
+ const first = V.key === null;
202
+ V.key = key;
203
+ // a bar moved from the keyboard keeps the focus across the rebuild; a bar saved by leaving it passes the focus on
204
+ const a = typeof document !== 'undefined' ? document.activeElement : null;
205
+ const inside = a && host.contains(a) && a.closest('[data-tl-item]');
206
+ const focusId = inside ? inside.getAttribute('data-tl-item') : V.next;
207
+ const scroll = host.querySelector('[data-tl-scroll]');
208
+ const left = scroll ? scroll.scrollLeft : 0;
209
+ host.replaceChildren(...build(host, ctx, V).filter(Boolean));
210
+ const s2 = host.querySelector('[data-tl-scroll]');
211
+ if (s2 && left) s2.scrollLeft = left;
212
+ if (first && typeof requestAnimationFrame === 'function') requestAnimationFrame(() => scrollToToday(host));
213
+ if (focusId) {
214
+ const sel = '[data-tl-item="' + (window.CSS && CSS.escape ? CSS.escape(focusId) : focusId) + '"] button';
215
+ const b = host.querySelector(sel);
216
+ if (b) b.focus({ preventScroll: true });
217
+ // if the browser finishes the blur by dropping the focus on the page, put it on the bar after the blur
218
+ if (!inside) {
219
+ setTimeout(() => {
220
+ const cur = document.activeElement;
221
+ const again = host.querySelector(sel);
222
+ if (again && (!cur || cur === document.body)) again.focus({ preventScroll: true });
223
+ }, 0);
224
+ }
225
+ }
226
+ }
227
+
228
+ function dispose(host) {
229
+ views.delete(host);
230
+ host.replaceChildren();
231
+ }
232
+
233
+ registerBreakdownView('timeline', { label: COPY.view, mount, dispose });
@@ -0,0 +1,244 @@
1
+ /* pm/bd-tree.js — the Breakdown's Tree (pm-os-absorb Run 1, PRD R18).
2
+ *
3
+ * Top to bottom: one Keep card per agent batch, then the initiative's stages in order — only those with items, proposals
4
+ * or an owed step — then Other for items whose stage this profile does not have. A stage is a header (name, CURRENT,
5
+ * "2 of 5 · 3 proposed", a chevron) over:
6
+ * - the owed row, when the workflow says a step of this stage is still open. It is DERIVED from the ladder, so it has
7
+ * no grip, no tick and no menu, and it never counts in progress;
8
+ * - "Show n done", which folds the stage's done items away;
9
+ * - the item rows: grip, tick, title over one meta line (owner · due · who changed it last, plus two signals at most),
10
+ * and a ⋯ menu;
11
+ * - Add item.
12
+ * Collapsing a stage is the viewer's own choice, kept in this browser only; it never reaches a ledger.
13
+ *
14
+ * Keyboard (handled on the pane body by panel-breakdown.js): ArrowUp/Down or j/k move between rows and headers, Space
15
+ * ticks, Enter opens, Alt+ArrowUp/Down moves a row, n adds an item after the focused row.
16
+ */
17
+
18
+ import { el } from './dom.js';
19
+ import { row, iconButton, dragHandle, badge, button, field, signal, toast, SEL } from './primitives.js';
20
+ import { COPY, shortDate, ownerLabel } from './breakdown-copy.js';
21
+ import { keepCard } from './keep-card.js';
22
+ import { removeLocal } from './bd-model.js';
23
+ import { readDoc } from './data.js';
24
+ import { COPY as IN_COPY } from './initiatives-copy.js';
25
+
26
+ /* The starter list of an empty breakdown (Run 2, PRD R21): the bullet lines of planning/work-starters.md, read once per
27
+ * workspace. A missing file is the plain case: the PM types the first item. */
28
+ const starterCache = new Map(); // ws -> string[] | null (null = no list) | 'loading'
29
+ export function parseStarters(text) {
30
+ const body = String(text || '').replace(/^---\n[\s\S]*?\n---\n/, '');
31
+ return body.split('\n').map((l) => /^\s*(?:[-*]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/.exec(l)).filter(Boolean).map((m) => m[1].slice(0, 200)).slice(0, 12);
32
+ }
33
+ function startersOf(ws, then) {
34
+ const c = starterCache.get(ws);
35
+ if (c === 'loading') return null;
36
+ if (c !== undefined) return c;
37
+ starterCache.set(ws, 'loading');
38
+ readDoc('planning/work-starters.md', ws).then((t) => parseStarters(t), () => null).then((list) => {
39
+ starterCache.set(ws, list && list.length ? list : null);
40
+ if (then) then();
41
+ });
42
+ return null;
43
+ }
44
+
45
+ /** The signals of one item, most important first, two at most. `place` 'board' leaves out the state, which its column shows. */
46
+ export function signalsFor(it, place) {
47
+ const out = [];
48
+ if (it.blocked) out.push(signal({ text: COPY.signals.blocked, tone: 'destructive', title: it.blocked.reason || null }));
49
+ if (it.noEvidence) out.push(signal({ text: COPY.signals.noEvidence, tone: 'warning' }));
50
+ if (it.late) out.push(signal({ text: COPY.signals.late, tone: 'destructive' }));
51
+ if (place !== 'board' && (it.state === 'doing' || it.state === 'review')) out.push(signal({ text: COPY.states[it.state] }));
52
+ if (it.overrideShown) out.push(signal({ text: COPY.signals.movedByYou }));
53
+ if (it.proposed) out.push(signal({ text: COPY.signals.proposed }));
54
+ return out.slice(0, 2);
55
+ }
56
+
57
+ /** The meta line: owner · due 20 Oct · by agent · sess-3. Who changed it last shows only when it was not the PM. */
58
+ export function metaFor(it) {
59
+ return [ownerLabel(it.owner), it.due ? COPY.due(shortDate(it.due)) : null, byPart(it)];
60
+ }
61
+
62
+ /** "by agent · sess-3", or nothing when the PM made the last change (the PM's own writes need no label). */
63
+ export function byPart(it) {
64
+ return it.byLabel && it.byLabel !== 'you' ? COPY.by(it.byLabel) : null;
65
+ }
66
+
67
+ function itemRow(ctx, id) {
68
+ const { P, L, A } = ctx;
69
+ const it = L.items[id];
70
+ const done = it.state === 'done';
71
+ const tick = iconButton({
72
+ icon: done ? 'square-check-big' : 'square', size: 'sm',
73
+ ariaLabel: done ? COPY.aria.markNotDone(it.title) : COPY.aria.markDone(it.title),
74
+ onClick: (e) => { e.stopPropagation(); A.tick(id); },
75
+ attrs: { tabindex: '-1', 'data-fk': 'tick:' + id, 'data-bd-tick': '' },
76
+ });
77
+ const more = iconButton({
78
+ icon: 'ellipsis', size: 'sm', ariaLabel: COPY.aria.more(it.title),
79
+ onClick: (e) => { e.stopPropagation(); A.menu(e.currentTarget, id, 'tree'); },
80
+ attrs: { tabindex: '-1', 'aria-haspopup': 'menu', 'data-fk': 'more:' + id, 'data-bd-more': '' },
81
+ });
82
+ return row({
83
+ title: it.title, titleIcon: it.milestone ? 'diamond' : null, meta: metaFor(it), signals: signalsFor(it, 'tree'),
84
+ lead: [dragHandle({ label: COPY.aria.reorder(it.title) }), tick], trail: [more],
85
+ onOpen: () => A.openSheet(id), done, proposed: it.proposed,
86
+ attrs: { class: 'bd-trow', 'data-item': id, 'data-state': it.state, 'data-stage': it.stage },
87
+ openAttrs: { 'data-fk': 'open:' + id, 'data-rove': 'row', tabindex: P.roveId === id ? '0' : '-1' },
88
+ });
89
+ }
90
+
91
+ /** The inline input that adds items. One per pane; it is kept across renders so typing is never lost. */
92
+ function addInput(ctx, stageId, afterId) {
93
+ const { P, A } = ctx;
94
+ const key = stageId + '|' + (afterId || '');
95
+ // the same input moves with the anchor row (a temporary id, then the written id), so a title typed meanwhile stays
96
+ if (P.addEl && P.addEl.stage === stageId) { P.addEl.key = key; return P.addEl.wrap; }
97
+ const f = field({ label: COPY.newItem, kind: 'text', hideLabel: true, placeholder: COPY.newItemHint, attrs: { class: 'bd-add__input', 'data-bd-add-input': stageId, maxlength: '200' } });
98
+ const input = f.control;
99
+ input.addEventListener('keydown', (e) => {
100
+ if (e.key === 'Enter') {
101
+ e.preventDefault(); e.stopImmediatePropagation();
102
+ const t = input.value.trim();
103
+ if (!t) { A.closeAdd(true); return; }
104
+ input.value = '';
105
+ A.addTitles(P.adding ? P.adding.stage : stageId, [t], P.adding ? P.adding.afterId : afterId);
106
+ } else if (e.key === 'Escape') {
107
+ e.preventDefault(); e.stopImmediatePropagation();
108
+ A.closeAdd(true);
109
+ } else e.stopPropagation();
110
+ }, true);
111
+ input.addEventListener('paste', (e) => {
112
+ const text = e.clipboardData ? e.clipboardData.getData('text') : '';
113
+ const lines = String(text || '').split(/\r?\n/).map((l) => l.replace(/^\s*(?:[-*]\s+|\[[ xX]?\]\s+|\d+[.)]\s+)/, '').trim()).filter(Boolean);
114
+ if (lines.length < 2) return;
115
+ e.preventDefault(); e.stopPropagation();
116
+ if (lines.length > 50) { toast({ text: COPY.tooMany(lines.length), tone: 'error' }); return; }
117
+ A.addTitles(P.adding ? P.adding.stage : stageId, lines.map((l) => l.slice(0, 200)), P.adding ? P.adding.afterId : afterId);
118
+ });
119
+ input.addEventListener('blur', () => {
120
+ setTimeout(() => { if (!P.rendering && P.addEl && P.addEl.wrap.contains(input) && document.activeElement !== input && !input.value.trim()) A.closeAdd(false); }, 0);
121
+ });
122
+ const wrap = el('div', { class: 'bd-add', 'data-bd-add-row': stageId }, el('div', { class: 'bd-add__field' }, f));
123
+ P.addEl = { key, stage: stageId, wrap, input };
124
+ return wrap;
125
+ }
126
+
127
+ function stageSection(ctx, s) {
128
+ const { P, L, A } = ctx;
129
+ const open = A.isExpanded(s);
130
+ const toggle = iconButton({
131
+ icon: open ? 'chevron-down' : 'chevron-right', expanded: open,
132
+ ariaLabel: open ? COPY.aria.collapse(s.label) : COPY.aria.expand(s.label),
133
+ onClick: (e) => { e.stopPropagation(); A.toggleStage(s.id); },
134
+ attrs: { class: 'bd-stage__toggle', 'data-fk': 'stage:' + s.id, 'data-rove': 'head', 'data-stage-toggle': s.id },
135
+ });
136
+ const counts = [s.count, s.proposed ? COPY.proposedCount(s.proposed) : null].filter(Boolean).join(' · ');
137
+ // The header is a drop target, not a control: the chevron is the one button that folds the stage.
138
+ const head = el('div', { class: 'bd-stage__head', 'data-stage-head': s.id },
139
+ el('span', { class: 'bd-stage__name' }, s.label),
140
+ s.current ? el('span', { class: 'bd-stage__current' }, COPY.current) : null,
141
+ counts ? el('span', { class: 'bd-stage__count' }, counts) : null,
142
+ toggle);
143
+ const kids = [head];
144
+ if (open) {
145
+ if (s.owed) {
146
+ kids.push(row({
147
+ owed: true, title: COPY.owed(s.owed.noun, s.owed.actor), lead: [],
148
+ trail: [badge({ text: COPY.derived, tone: 'outline' })],
149
+ attrs: { class: 'bd-owed', 'data-owed-key': s.owed.key, 'data-owed-actor': s.owed.actor },
150
+ }));
151
+ }
152
+ const all = s.items || [];
153
+ const doneIds = all.filter((id) => L.items[id].state === 'done');
154
+ const showDone = P.foldOpen.has(s.id);
155
+ if (doneIds.length) {
156
+ kids.push(button({
157
+ variant: 'ghost', size: 'sm', block: true, icon: showDone ? 'chevron-down' : 'chevron-right',
158
+ label: showDone ? COPY.hideDone(doneIds.length) : COPY.showDone(doneIds.length), onClick: () => A.toggleFold(s.id),
159
+ attrs: { class: 'bd-fold', 'aria-expanded': showDone ? 'true' : 'false', 'data-fk': 'fold:' + s.id, 'data-bd-fold': s.id },
160
+ }));
161
+ }
162
+ const rows = [];
163
+ for (const id of all) {
164
+ if (!showDone && L.items[id].state === 'done') continue;
165
+ rows.push(itemRow(ctx, id));
166
+ if (P.adding && P.adding.stage === s.id && P.adding.afterId === id) rows.push(addInput(ctx, s.id, id));
167
+ }
168
+ kids.push(el('div', { class: 'bd-list', 'data-stage-list': s.id }, rows));
169
+ if (P.adding && P.adding.stage === s.id && (!P.adding.afterId || !rows.some((r) => r.getAttribute && r.getAttribute('data-item') === P.adding.afterId))) {
170
+ kids.push(addInput(ctx, s.id, null));
171
+ } else {
172
+ kids.push(el('div', { class: 'bd-add' }, button({
173
+ label: COPY.addItem, variant: 'ghost', size: 'sm', icon: 'plus',
174
+ attrs: { class: 'bd-add__btn', 'data-fk': 'add:' + s.id, 'data-bd-add': s.id }, onClick: () => A.startAdd(s.id, null),
175
+ })));
176
+ }
177
+ }
178
+ return el('section', { class: 'bd-stage', 'data-stage-id': s.id, 'aria-label': s.label }, kids);
179
+ }
180
+
181
+ /** The Tree's nodes, in order. */
182
+ export function renderTree(ctx) {
183
+ const { P, L, A } = ctx;
184
+ const out = [];
185
+ for (const b of L.batches || []) {
186
+ if ((b.initiative || L.initiative) !== L.initiative) continue;
187
+ out.push(keepCard({
188
+ ws: P.ws, initiative: L.initiative, batch: b, place: 'tree',
189
+ onChanged: () => A.refresh(),
190
+ onHoldApply: (lay) => { for (const id of b.items || []) removeLocal(lay, id); },
191
+ onKeepApply: () => A.keepApplied(b),
192
+ }));
193
+ }
194
+ const visible = (L.stages || []).filter((s) => s.visible || (P.adding && P.adding.stage === s.id));
195
+ // An empty breakdown: no item in any stage, no proposal. The ladder may still owe rows (they show as stages below), so the
196
+ // empty state is judged by the items, not by the stages that show.
197
+ const noItems = !(L.stages || []).some((s) => (s.items || []).length) && !(L.other && L.other.items || []).length && !(L.batches || []).length;
198
+ if (noItems) {
199
+ const first = (L.stages || []).find((s) => s.current) || (L.stages || [])[0];
200
+ out.push(el('div', { class: 'bd-note' }, COPY.noBreakdown + '.'));
201
+ const starters = P.ws && first ? startersOf(P.ws, () => A.refresh()) : null;
202
+ if (first && starters && starters.length) {
203
+ out.push(el('div', { class: 'in-starters', 'data-bd-starters': '' },
204
+ el('p', { class: 'bd-note' }, IN_COPY.breakThisDown.head),
205
+ el('ul', { class: 'in-starters__list' }, starters.map((t) => el('li', null, t))),
206
+ button({ label: IN_COPY.breakThisDown.addAll(starters.length), variant: 'primary', size: 'sm', attrs: { 'data-bd-starters-add': '' }, onClick: () => A.addTitles(first.id, starters, null) }),
207
+ el('p', { class: 'bd-note' }, IN_COPY.breakThisDown.or)));
208
+ }
209
+ if (first) out.push(el('div', { class: 'bd-add' }, button({
210
+ label: IN_COPY.breakThisDown.label, variant: 'ghost', size: 'sm', iconEnd: 'chevron-right',
211
+ attrs: { class: 'bd-add__btn', 'data-fk': 'add:' + first.id, 'data-bd-break': '', ...(visible.length ? {} : { 'data-bd-add': first.id }) }, onClick: () => A.startAdd(first.id, null),
212
+ })));
213
+ }
214
+ for (const s of visible) out.push(stageSection(ctx, s));
215
+ if ((L.other && L.other.items || []).length) {
216
+ out.push(el('section', { class: 'bd-stage', 'data-stage-id': '', 'aria-label': COPY.other },
217
+ el('div', { class: 'bd-stage__head', 'data-stage-head': '' }, el('span', { class: 'bd-stage__name' }, COPY.other)),
218
+ el('div', { class: 'bd-list', 'data-stage-list': '', 'data-other': '' }, L.other.items.map((id) => itemRow(ctx, id)))));
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** Attach Sortable to the Tree: the stage lists reorder and move between stages; a stage header takes a row at its end. */
224
+ export function bindTreeDrag(ctx, Sortable, drags, baseOptions) {
225
+ const { P, A } = ctx;
226
+ const group = 'bd-tree:' + P.slug;
227
+ for (const list of P.bodyEl.querySelectorAll('[data-stage-list]')) {
228
+ const other = list.hasAttribute('data-other');
229
+ drags.add(new Sortable(list, baseOptions({
230
+ group: { name: group, pull: true, put: !other },
231
+ handle: SEL.drag, draggable: '.bd-trow', filter: SEL.owed + ', .bd-add', preventOnFilter: false,
232
+ onStart: () => { P.dragging = true; },
233
+ onEnd: (evt) => { P.dragging = false; A.dropTree(evt); },
234
+ })));
235
+ }
236
+ for (const head of P.bodyEl.querySelectorAll('[data-stage-head]')) {
237
+ const sid = head.getAttribute('data-stage-head');
238
+ if (!sid) continue;
239
+ drags.add(new Sortable(head, {
240
+ group: { name: group, pull: false, put: true }, sort: false, draggable: '.bd-trow',
241
+ onAdd: (evt) => { const id = evt.item.getAttribute('data-item'); evt.item.remove(); A.dropHeader(id, sid); },
242
+ }));
243
+ }
244
+ }