create-pmos 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) 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/bin/pmos.js +15 -1
  16. package/workspace/cut-stub.js +40 -0
  17. package/workspace/first-paint-audit.js +145 -0
  18. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  19. package/workspace/fixtures/initiatives-api/list.json +176 -0
  20. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  21. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  22. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  23. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  24. package/workspace/fixtures/report-api/draft.json +177 -0
  25. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  26. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  27. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  28. package/workspace/guide/board.md +81 -0
  29. package/workspace/guide/breakdown.md +154 -0
  30. package/workspace/guide/command-palette.md +75 -0
  31. package/workspace/guide/index.md +27 -17
  32. package/workspace/guide/initiatives-roadmap.md +130 -0
  33. package/workspace/guide/initiatives-screen.md +89 -0
  34. package/workspace/guide/initiatives-timeline.md +57 -0
  35. package/workspace/guide/integrated-work.md +94 -0
  36. package/workspace/guide/knowledge-graph.md +3 -68
  37. package/workspace/guide/layout.md +98 -32
  38. package/workspace/guide/live-notes.md +3 -3
  39. package/workspace/guide/pm-only-acts.md +89 -0
  40. package/workspace/guide/pm-only-lines.md +61 -0
  41. package/workspace/guide/reports-page.md +80 -0
  42. package/workspace/guide/reports.md +73 -0
  43. package/workspace/guide/timeline.md +70 -0
  44. package/workspace/guide/today.md +85 -0
  45. package/workspace/guide/validate-with-typesafe.md +1 -1
  46. package/workspace/lib/actor.js +91 -0
  47. package/workspace/lib/board-cache.js +32 -2
  48. package/workspace/lib/board-cli.js +108 -0
  49. package/workspace/lib/board-fold.js +232 -0
  50. package/workspace/lib/breakdown-export.js +80 -0
  51. package/workspace/lib/capture.js +98 -0
  52. package/workspace/lib/engine/plan-items-stage.js +271 -0
  53. package/workspace/lib/engine/workflow-state.js +28 -2
  54. package/workspace/lib/factory-join.js +158 -0
  55. package/workspace/lib/grant-mint.js +113 -0
  56. package/workspace/lib/health-rules-factory.js +92 -0
  57. package/workspace/lib/health-rules-plan.js +164 -0
  58. package/workspace/lib/health.js +79 -0
  59. package/workspace/lib/initiative-cli.js +222 -0
  60. package/workspace/lib/initiative-display.js +183 -0
  61. package/workspace/lib/initiative-needs.js +127 -0
  62. package/workspace/lib/initiative-rows.js +206 -0
  63. package/workspace/lib/initiative-writer.js +141 -0
  64. package/workspace/lib/issues.js +206 -0
  65. package/workspace/lib/item-cli.js +195 -0
  66. package/workspace/lib/item-spans.js +137 -0
  67. package/workspace/lib/measure-cli.js +100 -0
  68. package/workspace/lib/measurements.js +189 -0
  69. package/workspace/lib/mobile-nav.js +57 -53
  70. package/workspace/lib/needs-providers.js +120 -0
  71. package/workspace/lib/placements.js +440 -0
  72. package/workspace/lib/plan-items.js +922 -0
  73. package/workspace/lib/plan-ledgers.js +85 -0
  74. package/workspace/lib/plan-rules.js +72 -0
  75. package/workspace/lib/plan-tree.js +367 -0
  76. package/workspace/lib/pm-guard.js +174 -0
  77. package/workspace/lib/pm-lines-needs.js +331 -0
  78. package/workspace/lib/pmos-verbs.js +66 -0
  79. package/workspace/lib/proactive.js +7 -4
  80. package/workspace/lib/report-cli.js +152 -0
  81. package/workspace/lib/report-draft.js +579 -0
  82. package/workspace/lib/report-needs.js +213 -0
  83. package/workspace/lib/route-modules.js +128 -0
  84. package/workspace/lib/routes-board.js +91 -0
  85. package/workspace/lib/routes-capture.js +52 -0
  86. package/workspace/lib/routes-initiatives.js +371 -0
  87. package/workspace/lib/routes-reports.js +163 -0
  88. package/workspace/lib/routes-timeline.js +197 -0
  89. package/workspace/lib/routes-today.js +32 -0
  90. package/workspace/lib/today-cli.js +99 -0
  91. package/workspace/lib/today.js +333 -0
  92. package/workspace/lib/triage.js +163 -0
  93. package/workspace/lib/verify-work-cli.js +81 -0
  94. package/workspace/lib/work-check-needs.js +99 -0
  95. package/workspace/package-lock.json +1 -1
  96. package/workspace/package.json +4 -5
  97. package/workspace/public/index.html +29 -19
  98. package/workspace/public/pm/app.js +299 -30
  99. package/workspace/public/pm/bd-board.js +113 -0
  100. package/workspace/public/pm/bd-drag.js +59 -0
  101. package/workspace/public/pm/bd-model.js +152 -0
  102. package/workspace/public/pm/bd-timeline.js +233 -0
  103. package/workspace/public/pm/bd-tree.js +244 -0
  104. package/workspace/public/pm/board-copy.js +57 -0
  105. package/workspace/public/pm/breakdown-api.js +58 -0
  106. package/workspace/public/pm/breakdown-copy.js +138 -0
  107. package/workspace/public/pm/components.css +3054 -0
  108. package/workspace/public/pm/cycle-review.js +125 -0
  109. package/workspace/public/pm/data.js +40 -187
  110. package/workspace/public/pm/dest-board.js +438 -0
  111. package/workspace/public/pm/dest-initiatives.js +134 -0
  112. package/workspace/public/pm/dest-reports.js +270 -0
  113. package/workspace/public/pm/dest-settings.js +217 -0
  114. package/workspace/public/pm/dest-today.js +176 -0
  115. package/workspace/public/pm/dialogs.js +88 -0
  116. package/workspace/public/pm/display-word.js +56 -0
  117. package/workspace/public/pm/dock.js +105 -0
  118. package/workspace/public/pm/fixture.js +8 -227
  119. package/workspace/public/pm/frame-copy.js +94 -0
  120. package/workspace/public/pm/handoff-box.js +82 -0
  121. package/workspace/public/pm/http.js +88 -0
  122. package/workspace/public/pm/icons.js +53 -0
  123. package/workspace/public/pm/initiative-list.js +218 -0
  124. package/workspace/public/pm/initiative-sheet.js +261 -0
  125. package/workspace/public/pm/initiatives-api.js +59 -0
  126. package/workspace/public/pm/initiatives-copy.js +215 -0
  127. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  128. package/workspace/public/pm/initiatives-timeline.js +410 -0
  129. package/workspace/public/pm/initiatives-ui.js +123 -0
  130. package/workspace/public/pm/item-sheet.js +251 -0
  131. package/workspace/public/pm/keep-card.js +204 -0
  132. package/workspace/public/pm/lifecycle.js +7 -1
  133. package/workspace/public/pm/mobile.css +49 -20
  134. package/workspace/public/pm/mobile.js +157 -29
  135. package/workspace/public/pm/modes-live.js +7 -883
  136. package/workspace/public/pm/modes.js +32 -1024
  137. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  138. package/workspace/public/pm/next-step-ui.js +3 -2
  139. package/workspace/public/pm/palette-copy.js +51 -0
  140. package/workspace/public/pm/palette.js +312 -0
  141. package/workspace/public/pm/panel-breakdown.js +702 -0
  142. package/workspace/public/pm/panel-cockpit.js +63 -295
  143. package/workspace/public/pm/panel-detail.js +25 -172
  144. package/workspace/public/pm/panel-initiatives.js +1 -19
  145. package/workspace/public/pm/panel-repos.js +31 -81
  146. package/workspace/public/pm/panel-tabs.js +197 -110
  147. package/workspace/public/pm/panel-trees.js +5 -2
  148. package/workspace/public/pm/portfolio-report.js +61 -0
  149. package/workspace/public/pm/primitives.js +656 -0
  150. package/workspace/public/pm/print.css +33 -0
  151. package/workspace/public/pm/provider-cards.js +136 -0
  152. package/workspace/public/pm/registry.js +194 -0
  153. package/workspace/public/pm/release-card.js +110 -0
  154. package/workspace/public/pm/report-view.js +309 -0
  155. package/workspace/public/pm/reports-copy.js +114 -0
  156. package/workspace/public/pm/roadmap.js +271 -0
  157. package/workspace/public/pm/sig-watch.js +41 -0
  158. package/workspace/public/pm/state.js +8 -4
  159. package/workspace/public/pm/status-banner.js +13 -5
  160. package/workspace/public/pm/tab-doc.js +2 -4
  161. package/workspace/public/pm/tab-term.js +12 -2
  162. package/workspace/public/pm/tab-web.js +3 -2
  163. package/workspace/public/pm/timeline-copy.js +48 -0
  164. package/workspace/public/pm/today-cards.js +244 -0
  165. package/workspace/public/pm/today-copy.js +97 -0
  166. package/workspace/public/pm/tokens.css +16 -18
  167. package/workspace/public/pm/topbar.js +102 -0
  168. package/workspace/public/pm/triage-card.js +60 -0
  169. package/workspace/public/pm/undo-hold.js +103 -0
  170. package/workspace/public/pm/view-addrepo.js +2 -1
  171. package/workspace/public/pm/view-livenotes.js +3 -1
  172. package/workspace/public/pm/view-today.js +7 -162
  173. package/workspace/public/vendor/Sortable.min.js +2 -0
  174. package/workspace/public/vendor/lucide-sprite.js +79 -0
  175. package/workspace/replaced-tests.json +2864 -0
  176. package/workspace/run-tests.js +56 -5
  177. package/workspace/server.js +312 -36
  178. package/workspace/sheet-answer.js +61 -0
  179. package/workspace/skills/measure.skill +41 -0
  180. package/workspace/skills/report-biweekly.skill +23 -25
  181. package/workspace/skills/tracker-refresh.skill +50 -0
  182. package/workspace/test-files.json +19 -3
  183. package/workspace/test-lists/base.json +6 -0
  184. package/workspace/test-lists/run2-a.json +6 -0
  185. package/workspace/test-lists/run2-b.json +13 -0
  186. package/workspace/test-lists/run2-c.json +19 -0
  187. package/workspace/test-lists/run2-d.json +22 -0
  188. package/workspace/test-lists/run3-0.json +5 -0
  189. package/workspace/test-lists/run3-a.json +5 -0
  190. package/workspace/test-lists/run3-b.json +6 -0
  191. package/workspace/test-lists/run3-c.json +15 -0
  192. package/workspace/test-lists/run3-d.json +6 -0
  193. package/workspace/test-lists/run4-a.json +8 -0
  194. package/workspace/test-lists/run4-b.json +6 -0
  195. package/workspace/test-lists/run4-c.json +13 -0
  196. package/workspace/test-lists/run4-d.json +9 -0
  197. package/workspace/test-lists/run5-a.json +6 -0
  198. package/workspace/test-lists/run5-b.json +6 -0
  199. package/workspace/test-lists/run5-c.json +15 -0
  200. package/workspace/test-run.js +27 -1
  201. package/workspace/third-party/README.md +44 -0
  202. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  203. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  204. package/workspace/design-parity.js +0 -673
  205. package/workspace/public/pm/tab-task.js +0 -86
  206. package/workspace/public/pm/view-guide.js +0 -141
  207. package/workspace/public/pm/view-knowledge.js +0 -225
  208. package/workspace/public/pm/view-memory.js +0 -122
  209. package/workspace/public/pm/view-portfolio.js +0 -153
  210. package/workspace/public/pm/view-reports.js +0 -190
  211. package/workspace/public/pm/view-scheduled.js +0 -161
  212. package/workspace/public/pm/view-skills.js +0 -216
@@ -1,4 +1,13 @@
1
- /* pm/app.js — the shell of the four-panel workspace, and its render loop.
1
+ /* pm/app.js — the shell of the workspace, and its render loop.
2
+ *
3
+ * ── Two shells (pm-os-absorb Run 3, slice 3A) ───────────────────────────────────────────────
4
+ * THE FRAME is the default (PRD R9, R10, R15): one top bar (topbar.js: Repository ▾ · Today Initiatives Board Reports ·
5
+ * ⌘K · Sync when unsynced · Settings), the destination on screen (each one mounted by registry.js mountDestTab into a
6
+ * persistent host that carries data-dest="<id>"), the detail as a closable column when something opens one, and the
7
+ * agent dock (dock.js; it is panel 4, #pm-tabs) open on Today and a 44px rail everywhere else. The phone shows the same
8
+ * hosts one at a time under five tabs (pm/mobile.js). There is no left sidebar.
9
+ * THE FOUR-PANEL COCKPIT below stays for fixture mode (the parity boards, until slice 3D lifts the lock) and for
10
+ * `?layout=legacy`. Everything from here to "the frame" describes it.
2
11
  *
3
12
  * Owns: the root grid, the 44px top bar, the 26px status bar, the body grid's four tracks, the
4
13
  * three collapsed rails, and the keyboard (⌘K / Esc). Everything inside a panel belongs to that
@@ -27,6 +36,12 @@ import { state, setState, subscribe, flush, FIXTURE, LEGACY_LAYOUT_FLAG } from '
27
36
  * the shell's only part in it is to keep all four hosts mounted below the breakpoint and to hand
28
37
  * the module a repaint. See pm/mobile.js. */
29
38
  import { isMobile, mountMobile, applyMobile } from './mobile.js';
39
+ /* The frame's parts (Run 3, slice 3A). Small modules with no effect at import: a fixture page loads them and calls none. */
40
+ import { createTopbar, unsyncedOf } from './topbar.js';
41
+ import { createDockRail } from './dock.js';
42
+ import { SEL, ensureComponentsCss, iconButton, button, menu, toast } from './primitives.js';
43
+ import { ensureSprite } from './icons.js';
44
+ import { COPY as FC } from './frame-copy.js';
30
45
 
31
46
  /* The LEGACY layout is the three-column shell of column-layout: repositories with the list under them, the detail, the tabs, and
32
47
  * a list that a mode (skills, memory …) replaces. A phone still draws it until Run 4 of workspace-cockpit-nav, and `?layout=legacy`
@@ -35,16 +50,10 @@ const legacyLayout = () => LEGACY_LAYOUT_FLAG || isMobile();
35
50
 
36
51
  /* ── derived labels (the prototype's two title maps, lines 1409 / 1699) ─────────────────────── */
37
52
 
38
- const MODE_TITLE = {
39
- product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
40
- knowledge: 'knowledge', agent: 'agents', memory: 'memory', connect: 'connections', focus: 'focus',
41
- portfolio: 'portfolio', guide: 'guide', validate: 'validate', today: 'today',
42
- };
53
+ const MODE_TITLE = { product: 'initiatives', workflow: 'workflows', validate: 'validate', today: 'today' };
43
54
  const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
44
55
  const DETAIL_TITLE = {
45
- init: 'initiative', flow: 'workflow', sched: 'scheduled task', skill: 'skill', know: 'decision',
46
- agent: 'agent', mem: 'memory', prof: 'claude profile', intg: 'connection',
47
- addprod: 'add product', new: 'file new',
56
+ init: 'initiative', flow: 'workflow', skill: 'skill', prof: 'claude profile', addprod: 'add product', new: 'file new',
48
57
  };
49
58
 
50
59
  const detailTitle = () => DETAIL_TITLE[(state.detail && state.detail.kind) || ''] || 'detail';
@@ -69,7 +78,7 @@ const col0w = () => clampW(state.col0W, W0);
69
78
  * tabs fill the rest. The DOM still holds four hosts in the same order — repos, list, detail, tabs — and the grid
70
79
  * puts the list host under the repositories host (see placeHosts). Folded away, the navigation column is a 36px
71
80
  * rail; the list alone folds to a one-line bar under the repositories. */
72
- const gridCols = (legacy, focus) => focus ? 'minmax(0,1fr) 360px' : legacy
81
+ const gridCols = (legacy) => legacy
73
82
  ? (state.col0 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)'
74
83
  : '65px ' + (state.col1 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,470px)' : '36px') + ' minmax(0,1fr)';
75
84
 
@@ -193,8 +202,6 @@ const statusBar = el('div', {
193
202
  statusLeft,
194
203
  el('span', null, '⌘K jump · Esc closes tab'),
195
204
  );
196
- /* The rail's menu is drawn into this host, a sibling of the body so it can float over all four columns. */
197
- const menuHost = el('div', { 'data-shell': 'menu', style: 'display:contents' });
198
205
  const paletteHost = el('div', { id: 'pm-palette', 'data-shell': 'palette', style: 'display:contents' });
199
206
 
200
207
  /* The body grid's own geometry, in one place so the initial build and every repaint below cannot
@@ -203,8 +210,8 @@ const paletteHost = el('div', { id: 'pm-palette', 'data-shell': 'palette', style
203
210
  * would push the grid taller than the box it was given. */
204
211
  // A cockpit desktop is one row of four columns; the legacy layout is two rows (repositories, then the list under them) on a desktop
205
212
  // and one on a phone, where the hosts are shown one at a time.
206
- const BODY_GRID_STYLE = (legacy, phone, focus) =>
207
- 'display:grid;grid-template-columns:' + gridCols(legacy, focus) + ';grid-template-rows:' + (legacy && !phone ? 'auto minmax(0,1fr)' : 'minmax(0,1fr)') + ';min-height:0;overflow:hidden';
213
+ const BODY_GRID_STYLE = (legacy, phone) =>
214
+ 'display:grid;grid-template-columns:' + gridCols(legacy) + ';grid-template-rows:' + (legacy && !phone ? 'auto minmax(0,1fr)' : 'minmax(0,1fr)') + ';min-height:0;overflow:hidden';
208
215
 
209
216
  const bodyGrid = el('div', { 'data-shell': 'body', style: BODY_GRID_STYLE(false, false) });
210
217
 
@@ -251,9 +258,9 @@ const hostRepos = el('div', { id: 'pm-rail', style: HOST_STYLE.repos });
251
258
  const hostInits = el('div', { id: 'pm-list', style: HOST_STYLE.initsDesk });
252
259
  makeEdgeDrag(hostInits, 'col0W', W0, col0w);
253
260
  const hostDetail = el('div', { id: 'pm-detail', style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
254
- const hostTabs = el('div', { id: 'pm-tabs', style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
261
+ const hostTabs = el('div', { id: 'pm-tabs', class: 'lg-dock' });
255
262
 
256
- setChildren(root, [topBar, bodyGrid, statusBar, menuHost, paletteHost]);
263
+ setChildren(root, [topBar, bodyGrid, statusBar, paletteHost]);
257
264
 
258
265
  /* ── panel modules, loaded at boot and tolerated when absent ────────────────────────────────── */
259
266
 
@@ -315,7 +322,9 @@ function placeHosts(legacy, phone, kids) {
315
322
 
316
323
  let lastLegacy = null;
317
324
  function render() {
325
+ if (FRAME) return renderFrame();
318
326
  root.setAttribute('data-pm-theme', state.theme);
327
+ document.documentElement.setAttribute('data-pm-page-theme', state.theme);
319
328
  const phone = isMobile();
320
329
  const legacy = legacyLayout();
321
330
  // the mockup's body line-height is 1.45; the legacy shell's panels are drawn for 1.5
@@ -352,7 +361,7 @@ function render() {
352
361
  hostRepos.style.gap = legacy ? '' : '10px';
353
362
  hostRepos.style.padding = legacy ? '' : '12px 0';
354
363
  }
355
- const focus = !legacy && !phone && !!state.layoutFocus;
364
+ const focus = false; // the Focus arrangement is cut (R29); BODY_GRID_STYLE keeps its third parameter so this line does not change
356
365
  bodyGrid.setAttribute('style', BODY_GRID_STYLE(legacy, phone, focus));
357
366
 
358
367
  // On a phone there are no rails to collapse into and no second column to fold away: the four hosts all stay mounted, one is
@@ -363,29 +372,275 @@ function render() {
363
372
  else if (legacy) {
364
373
  const listHost = state.col1 ? hostInits : listBar;
365
374
  kids = [state.col0 ? hostRepos : railRepos(), state.col0 ? listHost : null, state.col2 ? hostDetail : rail2.btn, hostTabs].filter(Boolean);
366
- } else if (focus) kids = [hostDetail, hostTabs]; // Focus: the tabs and a 360px detail; hostTabs is never moved, only the others come and go
367
- else kids = [hostRepos, state.col1 ? hostInits : rail1.btn, state.col2 ? hostDetail : rail2.btn, hostTabs];
375
+ } else kids = [hostRepos, state.col1 ? hostInits : rail1.btn, state.col2 ? hostDetail : rail2.btn, hostTabs];
368
376
  setChildren(bodyGrid, kids);
369
377
  placeHosts(legacy, phone, kids);
370
- if (focus) { hostTabs.style.gridColumn = '1'; hostTabs.style.gridRow = '1'; hostDetail.style.gridColumn = '2'; hostDetail.style.gridRow = '1'; }
371
378
 
372
379
  if (legacy) {
373
380
  if (phone || state.col0) paint(mods.repos, 'renderRepos', hostRepos);
374
381
  if (phone || (state.col0 && state.col1)) paint(mods.inits, 'renderInitiatives', hostInits);
375
382
  if (phone || state.col2) paint(mods.detail, 'renderDetail', hostDetail);
376
- } else if (focus) {
377
- paint(mods.detail, 'renderDetail', hostDetail);
378
383
  } else {
379
384
  paint(mods.cockpit, 'renderRail', hostRepos);
380
385
  if (state.col1) paint(mods.cockpit, 'renderWorkList', hostInits);
381
386
  if (state.col2) paint(mods.detail, 'renderDetail', hostDetail);
382
387
  }
383
388
  paint(mods.tabs, 'renderTabs', hostTabs);
384
- paint(mods.cockpit, 'renderMenu', menuHost);
385
389
  paint(mods.cockpit, 'renderPalette', paletteHost);
386
390
  applyMobile();
387
391
  }
388
392
 
393
+ /* ── the frame (pm-os-absorb Run 3, slice 3A; PRD R9, R10, R15) ──────────────────────────────
394
+ * The default shell outside fixture mode and `?layout=legacy`. It REUSES the persistent hosts above: hostDetail moves
395
+ * into the detail column, hostTabs (panel 4, the live terminals) is the dock and is never re-parented once the frame is
396
+ * up, and hostRepos/hostInits are not mounted at all (there is no left sidebar). What is new:
397
+ * hostMain the place on screen: one persistent host per destination ([data-fr-host]); registry.js
398
+ * mountDestTab gives each its data-dest and draws the destination, or "<Label> arrives in Run N."
399
+ * the top bar topbar.js
400
+ * the dock rail dock.js; the dock is OPEN on Today and a 44px rail elsewhere. It never opens by itself: a tab
401
+ * opened while it is a rail waits in it. ⌘J and the rail's control toggle it.
402
+ * Destination, repository scope and Repository ▾ live here; what the PM picks is kept in this browser (FRAME_KEY).
403
+ * The detail column is `state.col2`: anything that sets `col2: true` (the palette, an initiative opener, Settings ›
404
+ * Profiles) opens it, and its close control or Esc sets it back to false. */
405
+
406
+ const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
407
+ const FRAME_KEY = 'pmos.workspace.pm.frame';
408
+ const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'settings']);
409
+
410
+ function readFrame() {
411
+ if (!FRAME) return {};
412
+ try { const j = JSON.parse(localStorage.getItem(FRAME_KEY) || 'null'); return j && typeof j === 'object' ? j : {}; } catch { return {}; }
413
+ }
414
+ const fr = (() => {
415
+ const s = readFrame();
416
+ return { dest: FRAME_DESTS.includes(s.dest) ? s.dest : 'today', scope: s.scope === 'all' ? 'all' : 'repo', params: {}, dock: {}, syncing: false, reg: null, gAt: 0, needs: 0 };
417
+ })();
418
+ function writeFrame() {
419
+ try { localStorage.setItem(FRAME_KEY, JSON.stringify({ dest: fr.dest, scope: fr.scope })); } catch { /* private mode: the default stands */ }
420
+ }
421
+
422
+ const activeRepo = () => (mods.repos && typeof mods.repos.activeProduct === 'function' ? mods.repos.activeProduct() : state.product || null);
423
+ /** The dock on the current destination: what the PM chose here with ⌘J or the rail, else open on Today only. */
424
+ const dockOpen = () => (Object.prototype.hasOwnProperty.call(fr.dock, fr.dest) ? fr.dock[fr.dest] : fr.dest === 'today');
425
+
426
+ function go(id, params) {
427
+ if (!FRAME_DESTS.includes(id)) return false;
428
+ const p = params || {};
429
+ if (p.repo) chooseRepo(p.repo);
430
+ fr.dest = id;
431
+ fr.params = { repo: p.repo || null, slug: p.slug || null };
432
+ writeFrame();
433
+ setState({ menuOpen: false, paletteOpen: false });
434
+ return true;
435
+ }
436
+ function setDock(open) { fr.dock[fr.dest] = !!open; setState({}); }
437
+ const toggleDock = () => setDock(!dockOpen());
438
+ const foldDock = () => setDock(false);
439
+ const openDock = () => setDock(true);
440
+ function chooseRepo(name) {
441
+ fr.scope = 'repo';
442
+ writeFrame();
443
+ if (name && name !== state.product && mods.repos && typeof mods.repos.selectRepo === 'function') mods.repos.selectRepo(name);
444
+ setState({ todayScope: 'repo' });
445
+ }
446
+ function chooseAll() { fr.scope = 'all'; writeFrame(); setState({ todayScope: 'all' }); }
447
+ const openPalette = () => setState({ paletteOpen: true, menuOpen: false });
448
+ const closeDetail = () => setState({ col2: false });
449
+
450
+ function repoMenu(anchor) {
451
+ const tuples = mods.repos && typeof mods.repos.repoTuples === 'function' ? mods.repos.repoTuples() : [];
452
+ const cur = fr.scope === 'all' ? null : activeRepo();
453
+ const items = tuples.map((t) => ({ label: t[0], icon: t[0] === cur ? 'check' : null, onSelect: () => chooseRepo(t[0]) }));
454
+ if (items.length) items.push({ sep: true });
455
+ items.push({ label: FC.repo.all, icon: fr.scope === 'all' ? 'check' : null, onSelect: chooseAll });
456
+ menu({ anchor, items, ariaLabel: FC.repo.menu });
457
+ }
458
+
459
+ /* Sync: the bar shows it only while the current repository is not in sync. The reading is local git (GET
460
+ * /api/sync/status), re-read a minute after the last one and at once after a Sync. */
461
+ const syncSeen = new Map(); // repo -> { at, st, pending }
462
+ function syncReading(repo) {
463
+ const d = mods.data;
464
+ if (!repo || !d || typeof d.syncStatusOf !== 'function' || (typeof d.wsOf === 'function' && !d.wsOf(repo))) return null;
465
+ const have = syncSeen.get(repo);
466
+ if (!have || (!have.pending && Date.now() - have.at > 60000)) {
467
+ const e = { at: Date.now(), st: have ? have.st : null, pending: true };
468
+ syncSeen.set(repo, e);
469
+ Promise.resolve(d.syncStatusOf(repo)).then((st) => { e.st = st; e.at = Date.now(); e.pending = false; setState({}); })
470
+ .catch(() => { e.pending = false; e.at = Date.now(); });
471
+ }
472
+ return syncSeen.get(repo).st;
473
+ }
474
+ async function runSync() {
475
+ const repo = fr.scope === 'all' ? null : activeRepo();
476
+ if (!repo || fr.syncing || !mods.data || typeof mods.data.syncRepository !== 'function') return;
477
+ fr.syncing = true;
478
+ setState({});
479
+ let res;
480
+ try { res = await mods.data.syncRepository(repo); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
481
+ fr.syncing = false;
482
+ syncSeen.delete(repo);
483
+ toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && res.step) || FC.sync.label, (res && (res.message || res.error)) || '') });
484
+ setState({});
485
+ }
486
+
487
+ let bar = null;
488
+ let dockRail = null;
489
+ let frDetail = null;
490
+ let pin = null;
491
+ const hostMain = el('div', { class: 'fr-main', 'data-fr': 'main' });
492
+ const destHosts = {};
493
+ for (const id of FRAME_DESTS) destHosts[id] = el('div', { class: 'fr-dest', 'data-fr-host': id, hidden: true });
494
+ const frEnd = el('span', { class: 'fr-end', 'aria-hidden': 'true' });
495
+
496
+ function setupFrame() {
497
+ ensureComponentsCss();
498
+ ensureSprite();
499
+ bar = createTopbar({ go, openPalette, repoMenu, sync: runSync });
500
+ document.addEventListener('pointerdown', (e) => { fr.downAt = e.target; }, true);
501
+ dockRail = createDockRail({ open: openDock });
502
+ frDetail = el('div', { class: 'fr-detail', 'data-fr': 'detail' },
503
+ el('div', { class: 'fr-detail__bar' }, iconButton({ icon: 'x', ariaLabel: FC.detail.close, onClick: closeDetail, attrs: { 'data-fr': 'detail-close' } })),
504
+ hostDetail);
505
+ pin = button({ label: FC.phone.pin, variant: 'outline', block: true, attrs: { class: 'fr-pin', 'data-fr': 'pin', 'aria-keyshortcuts': 'Meta+K Control+K' }, onClick: openPalette });
506
+ root.removeAttribute('style');
507
+ root.classList.add('fr-root');
508
+ bodyGrid.removeAttribute('style');
509
+ bodyGrid.classList.add('fr-body');
510
+ hostTabs.classList.add('fr-dock');
511
+ setChildren(root, [bar.node, bodyGrid, paletteHost, frEnd]);
512
+ // the frame opens with no detail: the column comes when something opens one
513
+ setState({ col2: false, todayScope: fr.scope });
514
+ }
515
+
516
+ function paintMain(phone) {
517
+ // the state-health banner (panel-repos.js) heads every place while the runtime state is not settled: it is not
518
+ // dismissible, and the frame has no repository panel for it to sit in
519
+ let banner = null;
520
+ try { banner = mods.repos && typeof mods.repos.stateHealthBanner === 'function' ? mods.repos.stateHealthBanner() : null; } catch { banner = null; }
521
+ const kids = [banner, phone && fr.dest === 'today' ? pin : null, ...FRAME_DESTS.map((id) => destHosts[id])];
522
+ setChildren(hostMain, kids);
523
+ for (const id of FRAME_DESTS) destHosts[id].hidden = id !== fr.dest;
524
+ const reg = fr.reg;
525
+ if (!reg) return;
526
+ const host = destHosts[fr.dest];
527
+ const scope = fr.scope;
528
+ try {
529
+ reg.mountDestTab(host, {
530
+ id: 'frame:' + fr.dest, kind: 'dest', view: fr.dest, title: FC.nav[fr.dest], role: fr.dest,
531
+ repo: scope === 'all' ? null : (fr.params.repo || activeRepo()), slug: fr.params.slug || null, scope,
532
+ });
533
+ } catch (e) { console.error('[pm] destination ' + fr.dest + ' failed:', e); }
534
+ }
535
+
536
+ function renderFrame() {
537
+ root.setAttribute('data-pm-theme', state.theme);
538
+ document.documentElement.setAttribute('data-pm-page-theme', state.theme);
539
+ if (mods.cockpit && typeof mods.cockpit.absorbMode === 'function' && mods.cockpit.absorbMode()) return;
540
+ const phone = isMobile();
541
+ const repo = fr.scope === 'all' ? null : activeRepo();
542
+ const st = repo ? syncReading(repo) : null;
543
+ let needs = 0;
544
+ try { needs = mods.cockpit && typeof mods.cockpit.needsCount === 'function' ? mods.cockpit.needsCount() : 0; } catch { needs = 0; }
545
+ fr.needs = needs;
546
+ fr.unsynced = unsyncedOf(st); // the phone bar (mobile.js) shows Sync on the same reading
547
+ bar.update({ dest: fr.dest, repoLabel: fr.scope === 'all' ? FC.repo.all : repo, needs, unsynced: fr.unsynced, syncing: fr.syncing });
548
+ const open = dockOpen();
549
+ bodyGrid.setAttribute('data-fr-dock', phone ? 'phone' : open ? 'open' : 'rail');
550
+ const detail = !!state.col2;
551
+ // Four children in every arrangement, hostTabs the fourth and last, so setChildren never moves it (a moved xterm loses
552
+ // its grid) and a reader of the body's fourth child still finds panel 4. A closed detail stays mounted, hidden.
553
+ frDetail.hidden = !phone && !detail;
554
+ setChildren(bodyGrid, phone ? [hostMain, dockRail, frDetail, hostTabs] : [hostMain, frDetail, dockRail, hostTabs]);
555
+ paintMain(phone);
556
+ if (detail || phone) paint(mods.detail, 'renderDetail', hostDetail);
557
+ paint(mods.tabs, 'renderTabs', hostTabs);
558
+ paint(mods.cockpit, 'renderPalette', paletteHost);
559
+ applyMobile();
560
+ }
561
+
562
+ /* The frame's keys. A key typed into a field, a terminal or an open sheet or palette is the field's. */
563
+ const ROW_SEL = [SEL.row, SEL.card, '[data-fr-row]'].join(', ');
564
+ function typingIn(t) {
565
+ if (!t || t.nodeType !== 1) return false;
566
+ return /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable || !!(t.closest && t.closest('.xterm'));
567
+ }
568
+ function shown(n) { return !!n && n.getClientRects().length > 0 && !n.closest('[hidden]'); }
569
+ function rowsHere() {
570
+ const scope = [destHosts[fr.dest], state.col2 ? frDetail : null].filter(Boolean);
571
+ const out = [];
572
+ for (const s of scope) for (const r of s.querySelectorAll(ROW_SEL)) if (shown(r) && !out.some((x) => x.contains(r))) out.push(r);
573
+ return out;
574
+ }
575
+ const OPEN_SEL = [SEL.rowOpen, SEL.cardOpen].join(', ');
576
+ const openTarget = (r) => r.querySelector(OPEN_SEL) || r.querySelector('button') || r;
577
+ function pressIn(r, key) {
578
+ const b = r && r.querySelector('[data-key~="' + key + '"]');
579
+ if (b && !b.disabled) { b.click(); return true; }
580
+ return false;
581
+ }
582
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports' };
583
+
584
+ function frameKey(e) {
585
+ const mod = e.metaKey || e.ctrlKey;
586
+ if (mod && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setState({ paletteOpen: !state.paletteOpen, menuOpen: false }); return; }
587
+ if (mod && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); if (!isMobile()) toggleDock(); return; }
588
+ if (e.key === 'Escape') {
589
+ if (state.paletteOpen) { setState({ paletteOpen: false }); return; }
590
+ if (state.menuOpen) { setState({ menuOpen: false }); return; }
591
+ if (modalOpen() || typingIn(e.target)) return;
592
+ if (state.col2 && !hostTabs.contains(e.target)) { closeDetail(); return; }
593
+ // Esc closes the active tab only while the dock is open, and only from the dock or from nowhere in particular (the
594
+ // page itself): an Esc pressed in the place or the detail never closes a tab, and a folded dock keeps its tabs. A
595
+ // click on blank space leaves the focus on the page, so the last pointer press says where the PM was.
596
+ const down = fr.downAt && fr.downAt.isConnected ? fr.downAt : null;
597
+ const inPlace = !!down && (hostMain.contains(down) || (!!frDetail && frDetail.contains(down)));
598
+ const fromPage = (!e.target || e.target === document.body || e.target === document.documentElement) && !inPlace;
599
+ if (dockOpen() && !isMobile() && (hostTabs.contains(e.target) || fromPage)) {
600
+ setState((s) => {
601
+ if (!s.active) return null;
602
+ const rest = s.tabs.filter((t) => t.id !== s.active);
603
+ return { tabs: rest, active: rest.length ? rest[rest.length - 1].id : null, splitId: s.splitId === s.active ? null : s.splitId };
604
+ });
605
+ }
606
+ return;
607
+ }
608
+ if (mod || state.paletteOpen || modalOpen() || typingIn(e.target) || hostTabs.contains(e.target)) return;
609
+ const k = e.key;
610
+ if (!e.altKey && fr.gAt && Date.now() - fr.gAt < 1200 && G_KEYS[k]) { fr.gAt = 0; e.preventDefault(); go(G_KEYS[k]); return; }
611
+ fr.gAt = !e.altKey && k === 'g' ? Date.now() : 0;
612
+ if (k === 'g') return;
613
+ const a = document.activeElement;
614
+ const cur = a && a !== document.body ? a.closest(ROW_SEL) : null;
615
+ if (k === 'j' || k === 'k') {
616
+ const rows = rowsHere();
617
+ if (!rows.length) return;
618
+ e.preventDefault();
619
+ const at = cur ? rows.findIndex((r) => r === cur || r.contains(cur) || cur.contains(r)) : -1;
620
+ const next = at < 0 ? (k === 'j' ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, at + (k === 'j' ? 1 : -1)));
621
+ openTarget(rows[next]).focus();
622
+ return;
623
+ }
624
+ if (!cur) return;
625
+ if (k === 'Enter' && a === cur) { e.preventDefault(); openTarget(cur).click(); return; }
626
+ // Space ticks and never opens: on a row, or on the row's open target, it presses the row's tick control, or nothing.
627
+ // On any other button of the row (⋯, a card's own button) Space is the browser's: it presses that button.
628
+ if (k === ' ') {
629
+ if (a !== cur && !(a.matches(OPEN_SEL) && a.closest(ROW_SEL) === cur)) return;
630
+ e.preventDefault(); pressIn(cur, 'space'); return;
631
+ }
632
+ if (e.altKey && (k === 'ArrowUp' || k === 'ArrowDown')) { if (pressIn(cur, k === 'ArrowUp' ? 'alt-up' : 'alt-down')) e.preventDefault(); return; }
633
+ if (!e.altKey && (k === 'a' || k === 'x')) { if (pressIn(cur, k)) e.preventDefault(); }
634
+ }
635
+
636
+ /* What the suites and the phone chrome reach the frame by. */
637
+ const frameApi = {
638
+ on: true, go, toggleDock, foldDock, openDock, chooseRepo, chooseAll, openPalette, repoMenu, closeDetail, sync: runSync,
639
+ get unsynced() { return !!fr.unsynced; },
640
+ get dest() { return fr.dest; }, get scope() { return fr.scope; }, get dockOpen() { return dockOpen(); },
641
+ get detailOpen() { return !!state.col2; }, get syncing() { return fr.syncing; }, get needs() { return fr.needs || 0; },
642
+ };
643
+
389
644
  /* ── keyboard ──────────────────────────────────────────────────────────────────────────────── */
390
645
 
391
646
  function focusSearch() {
@@ -394,7 +649,13 @@ function focusSearch() {
394
649
  if (input) input.focus();
395
650
  }
396
651
 
652
+ /** Is a modal <dialog> open? (`:modal` where the browser knows it, else any open dialog.) */
653
+ function modalOpen() {
654
+ try { return !!document.querySelector('dialog:modal'); } catch { return !!document.querySelector('dialog[open]'); }
655
+ }
656
+
397
657
  function onKeyDown(e) {
658
+ if (FRAME) { frameKey(e); return; }
398
659
  if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
399
660
  e.preventDefault();
400
661
  // the cockpit's ⌘K is the palette; the legacy layout (and a phone) keep focusing the list's filter
@@ -402,14 +663,10 @@ function onKeyDown(e) {
402
663
  else setState({ paletteOpen: !state.paletteOpen, menuOpen: false });
403
664
  return;
404
665
  }
405
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !legacyLayout()) {
406
- e.preventDefault();
407
- if (mods.cockpit && typeof mods.cockpit.toggleFocus === 'function') mods.cockpit.toggleFocus();
408
- return;
409
- }
410
666
  if (e.key === 'Escape' && state.paletteOpen) { setState({ paletteOpen: false }); return; }
411
667
  if (e.key === 'Escape' && state.menuOpen) { setState({ menuOpen: false }); return; }
412
- if (e.key === 'Escape' && state.layoutFocus) { setState({ layoutFocus: false }); return; }
668
+ // A modal sheet (the Breakdown's item sheet) handles its own Escape; one Escape never closes the sheet AND the tab.
669
+ if (e.key === 'Escape' && modalOpen()) return;
413
670
  if (e.key === 'Escape' && !/INPUT|TEXTAREA/.test(e.target && e.target.tagName)) {
414
671
  // Verbatim from the prototype: Esc closes the ACTIVE tab, the last remaining tab takes over,
415
672
  // and a split pointing at the closed tab is dropped rather than left dangling.
@@ -431,13 +688,25 @@ async function boot() {
431
688
  document.body.appendChild(root);
432
689
  subscribe(render);
433
690
  window.addEventListener('keydown', onKeyDown);
434
- mountMobile({ bodyGrid, render }); // wires the phone chrome; inert above 760px
691
+ if (FRAME) setupFrame();
692
+ mountMobile({ bodyGrid, render, frame: FRAME ? frameApi : null }); // wires the phone chrome; inert above 760px
435
693
  render(); // the shell is on screen before any panel module has resolved
436
694
  await loadModules();
437
695
  render();
696
+ /* pm-os-absorb Runs 2–5: the destinations and extensions each run adds (pm/registry.js), loaded after the panels and
697
+ * never in fixture mode. A module that is not on disk is not an error. `pmApp.open(id)` opens a destination. */
698
+ if (!FIXTURE) {
699
+ try {
700
+ const reg = await import('./registry.js'); window.pmApp.open = reg.openDestination; await reg.loadExtensions(); window.pmApp.registry = reg;
701
+ if (FRAME) { reg.setFrameHome((id, p) => go(id, p)); fr.reg = reg; } // the frame: pmApp.open(id) is the place on screen
702
+ }
703
+ catch (e) { console.error('[pm] the registry failed to load:', e); }
704
+ render();
705
+ }
438
706
  }
439
707
 
440
708
  /* A tiny surface for the browser suite and for a panel module that needs to force a paint. */
441
709
  window.pmApp = { root, render, state, setState, flush, fixture: FIXTURE, mods, hosts: { hostRepos, hostInits, hostDetail, hostTabs } };
710
+ if (FRAME) { window.pmApp.frame = frameApi; window.pmApp.hosts.hostMain = hostMain; }
442
711
 
443
712
  boot();
@@ -0,0 +1,113 @@
1
+ /* pm/bd-board.js — the Breakdown's Board (pm-os-absorb Run 1, PRD R19).
2
+ *
3
+ * Four columns, Todo · Doing · Review · Done, read from the same items map as the Tree, so a card's column is always its
4
+ * row's state. Done shows the last 14 days; older done items are counted ("3 older in the Tree") and stay in the Tree.
5
+ *
6
+ * When the pane is narrower than 600px — a phone, or the tabs column beside an open list and detail — the Board shows one
7
+ * column at a time behind a segmented switch (Todo 4 · Doing 2 · Review 1 · Done 3), and a card moves through the item
8
+ * sheet's state buttons: tap the card, tap the state. When it is wider, the four columns sit side by side and a card is
9
+ * dragged between them; a drop on Done is held for Undo, like any other Done.
10
+ */
11
+
12
+ import { el } from './dom.js';
13
+ import { card, tabs, dragHandle, iconButton, empty } from './primitives.js';
14
+ import { COPY, STATES, shortDate, ownerLabel } from './breakdown-copy.js';
15
+ import { stageLabel } from './bd-model.js';
16
+ import { signalsFor, byPart } from './bd-tree.js';
17
+
18
+ /** The width below which the Board shows one column at a time. Mirrors components.css's container query. */
19
+ export const WIDE_BOARD = 600;
20
+
21
+ function boardCard(ctx, id, wide) {
22
+ const { L, A } = ctx;
23
+ const it = L.items[id];
24
+ const meta = [stageLabel(L, it.stage), ownerLabel(it.owner), it.due ? COPY.due(shortDate(it.due)) : null, byPart(it)];
25
+ const trail = ctx.cardMenus
26
+ ? [iconButton({
27
+ icon: 'ellipsis', size: 'sm', ariaLabel: COPY.aria.more(it.title),
28
+ onClick: (e) => { e.stopPropagation(); A.menu(e.currentTarget, id, 'board'); },
29
+ attrs: { 'aria-haspopup': 'menu', 'data-fk': 'more:' + id, 'data-bd-more': '' },
30
+ })]
31
+ : null;
32
+ // Without the ⋯ button the card's menu is a right-click away, and Shift+F10 (or the ContextMenu key) for the keyboard.
33
+ const menuKeys = ctx.cardMenus ? {} : {
34
+ 'aria-keyshortcuts': 'Shift+F10',
35
+ onContextmenu: (e) => {
36
+ e.preventDefault();
37
+ const t = e.currentTarget;
38
+ // the key that raised this event (Shift+F10, ContextMenu) has already opened the menu below: a second call closes it
39
+ if (t.getAttribute('aria-expanded') === 'true') return;
40
+ // On Linux and macOS the event comes on the PRESS. A popover opened then is light-dismissed by the release, which
41
+ // lands outside it, so the menu opens after the release.
42
+ if (e.buttons) window.addEventListener('pointerup', () => setTimeout(() => A.menu(t, id, 'board'), 0), { once: true, capture: true });
43
+ else A.menu(t, id, 'board');
44
+ },
45
+ onKeydown: (e) => {
46
+ if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { e.preventDefault(); e.stopPropagation(); A.menu(e.currentTarget, id, 'board'); }
47
+ },
48
+ };
49
+ return card({
50
+ title: it.title, titleIcon: it.milestone ? 'diamond' : null, meta, signals: signalsFor(it, 'board'),
51
+ lead: wide ? [dragHandle({ label: COPY.aria.reorder(it.title) })] : null, trail,
52
+ muted: it.proposed, onOpen: () => A.openSheet(id),
53
+ attrs: { class: 'bd-card', 'data-item': id, 'data-state': it.state },
54
+ openAttrs: { 'data-fk': 'card:' + id, ...menuKeys },
55
+ });
56
+ }
57
+
58
+ /** The Board's nodes: the column switch (shown only when narrow) and the four columns. */
59
+ export function renderBoard(ctx) {
60
+ const { P, L, A } = ctx;
61
+ const wide = ctx.wide;
62
+ const shown = P.boardCol || 'todo';
63
+ const counts = Object.fromEntries(STATES.map((s) => [s, (L.board[s] || []).length]));
64
+ const sw = tabs({
65
+ variant: 'segmented', full: true, ariaLabel: COPY.columns, value: shown,
66
+ items: STATES.map((s) => ({ id: s, label: COPY.states[s], count: counts[s] })),
67
+ onChange: (c) => A.setBoardCol(c),
68
+ attrs: { class: 'bd-switch', 'data-bd-switch': '' },
69
+ });
70
+ for (const b of sw.querySelectorAll('[data-tab]')) b.setAttribute('data-fk', 'col:' + b.getAttribute('data-tab'));
71
+ const cols = STATES.map((s) => el('div', { class: 'bd-col' + (s === shown ? ' bd-col--shown' : ''), 'data-col-wrap': s },
72
+ el('div', { class: 'bd-col__head' }, el('span', null, COPY.states[s]), el('span', { class: 'bd-col__n' }, '· ' + counts[s])),
73
+ s === 'done' && L.board.doneOlder > 0 ? el('div', { class: 'bd-col__older', 'data-bd-older': String(L.board.doneOlder) }, COPY.olderInTree(L.board.doneOlder)) : null,
74
+ // an empty column says so, so a blank area never reads as a failed load
75
+ !counts[s] && !(s === 'done' && L.board.doneOlder > 0) ? empty({ text: COPY.emptyColumn(COPY.states[s]), attrs: { 'data-bd-empty-col': s } }) : null,
76
+ // the list fills its column, so the whole column height takes a drop
77
+ el('div', { class: 'bd-col__list', 'data-col': s, 'aria-label': COPY.states[s] }, (L.board[s] || []).map((id) => boardCard(ctx, id, wide)))));
78
+ return [sw, el('div', { class: 'bd-board', 'data-bd-board': wide ? 'wide' : 'narrow' }, cols)];
79
+ }
80
+
81
+ /**
82
+ * The column under the pointer when the card is dropped. Sortable moves the card only when the pointer is over another
83
+ * card or past the end of a list, so a drop in the gap between two cards would otherwise land in the last column the
84
+ * card passed through. The column under the pointer is the one the person chose.
85
+ */
86
+ function columnUnder(P, e) {
87
+ const pt = e && e.changedTouches && e.changedTouches.length ? e.changedTouches[0] : e;
88
+ if (!pt || !Number.isFinite(pt.clientX) || !Number.isFinite(pt.clientY) || (pt.clientX === 0 && pt.clientY === 0)) return null;
89
+ const under = document.elementFromPoint(pt.clientX, pt.clientY);
90
+ const col = under && under.closest('[data-col-wrap]');
91
+ return col && P.root.contains(col) ? col.getAttribute('data-col-wrap') : null;
92
+ }
93
+
94
+ /** Attach Sortable to the four columns. Only when they are side by side: one column at a time has nowhere to drop. */
95
+ export function bindBoardDrag(ctx, Sortable, drags, baseOptions) {
96
+ const { P, A } = ctx;
97
+ if (!ctx.wide) return;
98
+ const group = 'bd-board:' + P.slug;
99
+ for (const list of P.bodyEl.querySelectorAll('[data-col]')) {
100
+ drags.add(new Sortable(list, baseOptions({
101
+ group, sort: false, draggable: '.bd-card',
102
+ onStart: () => { P.dragging = true; },
103
+ onEnd: (evt) => {
104
+ P.dragging = false;
105
+ const id = evt.item.getAttribute('data-item');
106
+ const from = evt.from && evt.from.getAttribute('data-col');
107
+ const to = columnUnder(P, evt.originalEvent) || (evt.to && evt.to.getAttribute('data-col'));
108
+ if (id && to && to !== from) A.moveTo(id, to);
109
+ else A.rerender();
110
+ },
111
+ })));
112
+ }
113
+ }
@@ -0,0 +1,59 @@
1
+ /* pm/bd-drag.js — drag for the Breakdown, on the vendored SortableJS 1.15.7 (D86 point 1).
2
+ *
3
+ * Sortable is loaded on first use as a classic script from /vendor/Sortable.min.js (its UMD build sets window.Sortable).
4
+ * Drag is never the only way: every move it can make is also on the keyboard (Alt+ArrowUp/Down in the Tree) and in a
5
+ * menu or the item sheet's state buttons. If the script does not load, those paths still work and one warning is logged.
6
+ *
7
+ * Touch: a 150 ms hold starts a drag and a plain swipe scrolls (`delay` + `delayOnTouchOnly` + `touchStartThreshold`,
8
+ * and `touch-action: pan-y` on the grip in components.css, so a swipe that starts on a grip scrolls too).
9
+ * iOS Safari gets Sortable's own pointer fallback, because its native drag-and-drop does not start from a touch hold.
10
+ * The fallback clone stays inside its list (`fallbackOnBody: false`): appended to <body> it would sit outside
11
+ * [data-pm-theme] and the pane's font, and the PM would drag a dark slab of serif text in the light theme.
12
+ */
13
+
14
+ export const IOS = typeof navigator !== 'undefined'
15
+ && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1));
16
+
17
+ export const baseOptions = (extra) => ({
18
+ animation: 120, delay: 150, delayOnTouchOnly: true, touchStartThreshold: 5,
19
+ forceFallback: IOS, fallbackTolerance: 3, fallbackOnBody: false,
20
+ ghostClass: 'bd-ghost', chosenClass: 'bd-chosen', dragClass: 'bd-dragging', fallbackClass: 'bd-fallback',
21
+ ...extra,
22
+ });
23
+
24
+ let loading = null;
25
+ let warned = false;
26
+
27
+ /** Resolve to the Sortable constructor, loading the vendored script once. Resolves to null when it cannot load. */
28
+ export function loadSortable() {
29
+ if (typeof window === 'undefined') return Promise.resolve(null);
30
+ if (window.Sortable) return Promise.resolve(window.Sortable);
31
+ if (!loading) {
32
+ loading = new Promise((resolve) => {
33
+ const s = document.createElement('script');
34
+ s.src = '/vendor/Sortable.min.js';
35
+ s.async = true;
36
+ s.onload = () => resolve(window.Sortable || null);
37
+ s.onerror = () => {
38
+ if (!warned) { warned = true; console.warn('[breakdown] drag is unavailable: /vendor/Sortable.min.js did not load. The keyboard and the menus still move items.'); }
39
+ loading = null;
40
+ resolve(null);
41
+ };
42
+ document.head.appendChild(s);
43
+ });
44
+ }
45
+ return loading;
46
+ }
47
+
48
+ /**
49
+ * Sortable instances for one pane, destroyed together before every rebuild. The DOM is always rebuilt from the model
50
+ * after a drop, so the list Sortable rearranged is never the one that stays on screen.
51
+ */
52
+ export function dragSet() {
53
+ const all = [];
54
+ return {
55
+ add(inst) { if (inst) all.push(inst); return inst; },
56
+ destroy() { while (all.length) { const s = all.pop(); try { s.destroy(); } catch { /* already gone */ } } },
57
+ get size() { return all.length; },
58
+ };
59
+ }