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
@@ -9,8 +9,8 @@
9
9
  * ── What this module owns ───────────────────────────────────────────────────────────────────
10
10
  * the tab bar · + session · split · the empty state · which pane hosts exist and where they sit
11
11
  * · the scratchpad · the bottom bar (scratchpad toggle, active-tab title, copy)
12
- * The BODY of a tab belongs to its own module — `mountDoc` (tab-doc.js), `mountTask`
13
- * (tab-task.js), `mountTerm` (tab-term.js). This file never renders a tab's contents.
12
+ * The BODY of a tab belongs to its own module — `mountDoc` (tab-doc.js), `mountTerm` (tab-term.js). This file never
13
+ * renders a tab's contents.
14
14
  *
15
15
  * ── Why the pane hosts are cached ───────────────────────────────────────────────────────────
16
16
  * A term pane hosts a live xterm attached to a real PTY. Rebuilding that element on an unrelated
@@ -32,10 +32,13 @@ import { isMobile } from './mobile.js';
32
32
  import * as FX from './fixture.js';
33
33
  import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
34
34
  import { mountDoc, disposeDoc, docText } from './tab-doc.js';
35
- import { mountTask } from './tab-task.js';
36
35
  import { mountWeb, promptWeb } from './tab-web.js';
37
36
  import { newSessionBlock, openNewSession } from './panel-newsess.js';
38
37
  import { boardOf, currentProduct } from './board-read.js';
38
+ import { field, button, toast, ensureComponentsCss } from './primitives.js';
39
+ import { COPY as PAL } from './palette-copy.js';
40
+ import { askChoice } from './dialogs.js';
41
+ import { icon, ensureSprite } from './icons.js';
39
42
 
40
43
  /* tab-term.js is loaded LAZILY and on purpose: it is another module's file, a static import of a
41
44
  * file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
@@ -49,8 +52,20 @@ import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some
49
52
  import('./tab-term.js')
50
53
  .then((m) => { termMod = m; setState({}); })
51
54
  .catch(() => { termMod = null; });
55
+ /* pm-os-absorb Run 1 — panel-breakdown.js is loaded LAZILY for the same reason, and for one more: it is a 14-module graph
56
+ * (primitives, icons, bd-*, item-sheet, handoff-box, keep-card, undo-hold), and a static import makes THIS module's
57
+ * evaluation wait for all of it. Those fetches queue behind the server's synchronous boot reads (/api/initiative,
58
+ * /api/library), so panel 4 — and with it tab-term.js and the terminal socket — came up after loadRepos instead of
59
+ * beside it, and a reload's socket opened ~1.5 s late. A Breakdown tab restored on reload repaints when it lands. */
60
+ let breakdownMod = null;
61
+ import('./panel-breakdown.js')
62
+ .then((m) => { breakdownMod = m; if ((state.tabs || []).some((t) => t.kind === 'breakdown')) setState({}); })
63
+ .catch(() => { breakdownMod = null; });
52
64
 
53
65
  /** `{{ expr }}` — one interpolated value in its own span, the way the prototype's runtime emits it. */
66
+ /* A dock control's face: a lucide icon from the vendored sprite, then its word (C6: no Unicode glyph controls). */
67
+ const iconLabel = (name, text) => (text ? [icon(name), el('span', null, text)] : [icon(name)]);
68
+ const closeIcon = () => { ensureSprite(); return icon('x'); };
54
69
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
55
70
 
56
71
  /* ── the board, read exactly the way panels 2 and 3 read it (board-read.js) ─────────────────── */
@@ -116,25 +131,6 @@ export function openTerm(slug, sess) {
116
131
  return openTermTab(slug, sess);
117
132
  }
118
133
 
119
- /**
120
- * Open a task tab. Accepts the finished tab record panel 3 builds (`openTask(tab)`), or a task
121
- * plus its initiative (`openTask(task, 'workspace-pm-spine')`), in which case the tab id is the
122
- * prototype's `task:<slug>:<phase>#<index>` (line 1750).
123
- */
124
- export function openTask(task, initiative) {
125
- if (!task) return;
126
- if (initiative === undefined && task.id) return openTab(task);
127
- const slug = typeof initiative === 'string' ? initiative
128
- : (initiative && (initiative.slug || initiative.id)) || task.slug;
129
- const idx = task.index != null ? task.index : 0;
130
- return openTab({
131
- id: task.id || FX.taskTabId(slug, task.phase, idx),
132
- kind: 'task', role: 'TASK', title: task.title, slug, repo: slugProductOf()[slug],
133
- phase: task.phase, status: task.status, instruction: task.instruction, input: task.input,
134
- expect: task.expect, evalNote: task.evalNote, output: task.output,
135
- });
136
- }
137
-
138
134
  /** Close one tab (lines 1971–1974): the last remaining tab takes over, a dangling split is dropped. */
139
135
  export function closeTab(id) {
140
136
  setState((s) => {
@@ -330,13 +326,13 @@ export function toggleSplit() {
330
326
 
331
327
  /* ── persistent chrome ─────────────────────────────────────────────────────────────────────── */
332
328
 
333
- const BAR_STYLE = 'display:flex;gap:2px;align-items:stretch;padding:6px 8px 0;border-bottom:1px solid oklch(1 0 0 / 10%);min-height:48px;min-width:0;overflow-x:auto';
334
- const CHIP_STYLE = 'flex:none;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 11px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap;margin-left:4px';
335
- const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 12px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap';
336
- const CHIP_HOVER = 'border-color:oklch(0.56 0.021 213.5)';
329
+ const BAR_STYLE = 'display:flex;gap:2px;align-items:stretch;padding:6px 8px 0;border-bottom:1px solid var(--border);min-height:48px;min-width:0;overflow-x:auto';
330
+ const CHIP_STYLE = 'flex:none;align-self:center;font:11px var(--mono);color:var(--muted-foreground);padding:5px 11px;min-height:30px;background:var(--secondary);border:1px solid var(--border);white-space:nowrap;margin-left:4px';
331
+ const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font:11px var(--mono);color:var(--muted-foreground);padding:5px 12px;min-height:30px;background:var(--secondary);border:1px solid var(--border);white-space:nowrap';
332
+ const CHIP_HOVER = 'border-color:var(--ring)';
337
333
 
338
- /* `data-term-chrome`: the strip and the bottom bar sit on --term-bg, which is dark in BOTH themes (D41), so they take the dark
339
- * theme's tokens in light mode too (tokens.css). Without it the light theme's dark text was drawn on the dark band, unreadable. */
334
+ /* The strip and the bottom bar are chrome: they follow the theme (PRD R27). Only the terminal itself stays dark (D41): its pane
335
+ * paints --term-bg. `data-term-chrome` stays as a hook for the suites. */
340
336
  const tabBar = el('div', { 'data-tabs': 'bar', 'data-term-chrome': '', style: BAR_STYLE, onWheel: stripWheel });
341
337
 
342
338
  /* ── the cockpit's tab strip (workspace-cockpit-nav Run 4, planning/design/cockpit-mockup/WsA.dc.html) ─────────────────────────
@@ -358,9 +354,10 @@ const CK_BAR = 'flex:none;display:flex;align-items:flex-end;gap:4px;padding:0 10
358
354
  const CK_VBAR = 'flex:none;width:224px;display:flex;flex-direction:column;align-items:stretch;gap:2px;padding:8px 6px;border-right:1px solid var(--border);min-height:0;overflow-y:auto;overflow-x:hidden';
359
355
  const tabsMain = el('div', { 'data-tabs': 'main', style: 'flex:1;min-height:0;min-width:0;display:flex;flex-direction:column' });
360
356
  const orientLabel = iv('');
357
+ const orientIcon = el('span', null);
361
358
  const orientBtn = el('button', {
362
359
  onClick: () => setState({ tabsVertical: !state.tabsVertical }), 'data-tabs': 'orient',
363
- }, orientLabel);
360
+ }, orientIcon, orientLabel);
364
361
  /* In the strip, a mouse wheel scrolls the tabs sideways: with many tabs open the strip is wider than the panel, and a wheel that
365
362
  * did nothing there left only the thin scrollbar. */
366
363
  function stripWheel(e) {
@@ -375,13 +372,14 @@ const CK_BTN = 'flex:none;margin:0 0 4px;height:30px;display:grid;place-items:ce
375
372
 
376
373
  const newSessionBtn = el('button', {
377
374
  onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
378
- }, '+ session');
375
+ 'aria-label': 'new session',
376
+ }, ...iconLabel('plus', 'session'));
379
377
 
380
378
  /* overhaul-r5 (D69) — open a web page in a tab, by typing its address. Live mode only. */
381
379
  const newWebBtn = el('button', {
382
380
  onClick: () => promptWeb(), style: CHIP_STYLE, hover: CHIP_HOVER,
383
- title: 'open a web page in a tab (http/https; it loads only when you open it)', 'data-r5': 'new-web',
384
- }, '🌐 web');
381
+ title: 'open a web page in a tab (http/https; it loads only when you open it)', 'data-r5': 'new-web', 'aria-label': 'open a web page',
382
+ }, ...iconLabel('globe', 'web'));
385
383
 
386
384
  /* `⋯ options` — the demoted composer, restored (workspace-session-preflight).
387
385
  *
@@ -395,17 +393,17 @@ const newWebBtn = el('button', {
395
393
  const newSessionOptsBtn = el('button', {
396
394
  onClick: () => openNewSession(selSlug()),
397
395
  style: CHIP_STYLE, hover: CHIP_HOVER,
398
- title: 'choose the branch, the agent and the profile before the session starts',
399
- }, '⋯ options');
396
+ title: 'choose the branch, the agent and the profile before the session starts', 'aria-label': 'session options',
397
+ }, ...iconLabel('ellipsis', 'options'));
400
398
 
401
- const splitLabel = iv('⫽ split');
399
+ const splitLabel = iv('split');
402
400
  const splitBtn = el('button', {
403
401
  onClick: () => toggleSplit(), style: SPLIT_STYLE, hover: CHIP_HOVER,
404
- }, splitLabel);
402
+ }, icon('columns-2'), splitLabel);
405
403
 
406
404
  const emptyState = el('div', {
407
405
  style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
408
- }, "nothing open — click a task's output or an artifact, or + session above");
406
+ }, "Nothing is open. Open an artifact, or press + session to start a session.");
409
407
 
410
408
  const panesRow = el('div', { 'data-tabs': 'panes', style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
411
409
 
@@ -422,7 +420,7 @@ const panesRow = el('div', { 'data-tabs': 'panes', style: 'flex:1;display:flex;m
422
420
  * The store is `state.scratchPads`, keyed by:
423
421
  * `sess:<server session id>` — a terminal tab bound to a live PTY. The id is the SERVER's, so two
424
422
  * sessions both called `agent 1` in two repositories are two pads.
425
- * `ws:<workspace id>` — everything else (a doc, a task, nothing open): the repository pad.
423
+ * `ws:<workspace id>` — everything else (a doc, nothing open): the repository pad.
426
424
  * That is exactly the key space `/api/scratchpad` already stores under — the endpoint has been
427
425
  * per-session all along, and it is the client half that went missing. `state.scratchText` remains
428
426
  * the text ON SCREEN, so the copy button, `⇥ send to terminal` and the fixture render are unchanged.
@@ -529,6 +527,7 @@ const scratchGrip = el('div', {
529
527
  /* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
530
528
 
531
529
  const padLoading = new Set(); // keys with a read in flight — one read per key, ever
530
+ const padTyped = new Set(); // pads typed into since this page loaded: a late server read never overwrites them
532
531
  let padTimer = null;
533
532
  let padPending = null; // {key, text} not yet written
534
533
 
@@ -571,7 +570,7 @@ function onScratchInput(text) {
571
570
  scratchText: text,
572
571
  scratchPads: key ? { ...s.scratchPads, [key]: text } : s.scratchPads,
573
572
  }));
574
- if (key) schedulePadSave(key, text);
573
+ if (key) { padTyped.add(key); schedulePadSave(key, text); }
575
574
  }
576
575
 
577
576
  /**
@@ -592,6 +591,8 @@ function syncPad() {
592
591
  padLoading.add(key);
593
592
  const landed = (text) => {
594
593
  padLoading.delete(key);
594
+ // text typed while the read was in flight is newer than what the read returns: it stays (and is the save in flight)
595
+ if (padTyped.has(key)) return;
595
596
  setState((s) => ({
596
597
  scratchPads: { ...s.scratchPads, [key]: text },
597
598
  // …and onto the screen only if this is still the pad on screen. A PM who switched tabs while
@@ -671,31 +672,96 @@ const scratchBlock = el('div', {
671
672
  scratchSendBtn, scratchCopyBtn, scratchClearBtn, scratchNote),
672
673
  );
673
674
 
675
+ /**
676
+ * Paint the scratchpad drawer and the bottom bar's scratchpad toggle for the active tab `act`.
677
+ * renderTabs calls it on one line, so the scratchpad (3C) and the tab strip (3A) are separate hunks.
678
+ */
679
+ function renderScratch(act) {
680
+ /* `defaultValue` IS the textarea's child text node — the rendered text lives in the DOM, not only
681
+ * in a property, which is how the prototype's runtime leaves it. Writing it costs a typed-in box
682
+ * nothing; `value` is written only when it differs, so the caret stays where the PM put it. */
683
+ const scratchText = state.scratchText || '';
684
+ if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
685
+ if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
686
+ /* The drawer's height, real mode only. The style ATTRIBUTE keeps the prototype's string; only the
687
+ * resolved `height` property is written, so the fixture render — which never reaches here — stays
688
+ * byte-identical to the design. Written on every paint rather than only on resize, because the
689
+ * restored value has to land on the first one. */
690
+ if (!FIXTURE) {
691
+ const h = clampScratchH(state.scratchH || SCRATCH_DEFAULT) + 'px';
692
+ if (scratchTa.style.height !== h) scratchTa.style.height = h;
693
+ }
694
+ scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
695
+ const canSend = !!(act && act.kind === 'term');
696
+ scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
697
+ scratchSendBtn.setAttribute('style', canSend ? PAD_BTN_PRIMARY : PAD_BTN_OFF);
698
+ scratchClearBtn.disabled = !scratchText;
699
+ scratchClearBtn.style.opacity = scratchText ? '' : '0.5';
700
+ /* The header names whose pad this is, and the note says what keeps it and what Send does. Both are
701
+ * read from the same facts `padKey` uses, so they cannot claim a pad the drawer is not showing. */
702
+ scratchHeadLabel.textContent = FIXTURE ? 'scratchpad'
703
+ : (canSend ? 'scratchpad · this session' : 'scratchpad · this repository');
704
+ scratchNote.textContent = FIXTURE ? 'Kept until the page closes.'
705
+ : (canSend ? 'Kept for this session. Send also presses Enter.' : 'Kept for this repository. Open a terminal to send.');
706
+ scratchToggleLabel.textContent = state.scratchOpen ? '▼ scratchpad' : '▲ scratchpad';
707
+ if (!FIXTURE) paintBottom(act);
708
+ }
709
+
674
710
  /* ── the bottom bar (lines 992–996) ─────────────────────────────────────────────────────────── */
675
711
 
676
712
  const scratchToggleLabel = iv('▲ scratchpad');
677
713
  const colTitleLabel = iv('scratchpad');
678
714
  const copyLabel = iv('copy');
679
715
 
680
- const bottomBar = el('div', {
681
- 'data-tabs': 'bottom', 'data-term-chrome': '',
682
- style: 'display:flex;gap:10px;align-items:center;padding:5px 12px;border-top:1px solid oklch(1 0 0 / 10%);font-size:11px;color:oklch(0.6 0.02 213)',
683
- },
684
- el('button', {
685
- onClick: () => setState((s) => ({ scratchOpen: !s.scratchOpen })),
686
- style: 'flex:none;color:oklch(0.72 0.014 214);font-size:11px;padding:3px 6px;white-space:nowrap',
687
- hover: 'color:oklch(0.987 0.002 197.1)',
688
- }, scratchToggleLabel),
689
- el('span', {
690
- style: 'font-family:var(--mono);color:#c4ccce;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
691
- }, colTitleLabel),
692
- el('button', {
693
- onClick: () => copyActive(),
694
- style: 'flex:none;margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--brand);padding:3px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap',
695
- hover: CHIP_HOVER,
696
- }, copyLabel),
716
+ const bottomBar = el('div', { 'data-tabs': 'bottom', 'data-term-chrome': '', class: 'lg-dock-bottom' },
717
+ button({ label: scratchToggleLabel, variant: 'ghost', size: 'sm', onClick: () => setState((s) => ({ scratchOpen: !s.scratchOpen })) }),
718
+ el('span', { class: 'lg-dock-bottom__title' }, colTitleLabel),
719
+ button({ label: copyLabel, variant: 'outline', size: 'sm', onClick: () => copyActive() }),
697
720
  );
698
721
 
722
+ /* pm-os-absorb Run 3 (R11) took the scratchpad toggle out of the bar, and with it the only way to the pad on a desktop.
723
+ * 0.53.1 puts it back: paintBottom lays the bar out as the scratchpad toggle, the session's name and `copy`, or, on a
724
+ * phone, the terminal's compose line (the same per-session pad). Fixture mode keeps the prototype's bar. */
725
+ const [bottomToggle, bottomTitle, bottomCopy] = bottomBar.children;
726
+ bottomToggle.setAttribute('data-scratch', 'toggle');
727
+
728
+ /* The compose line: one message field and Send on the SAME per-session pad as the drawer (padKey), so the text is kept
729
+ * on this machine and never crosses sessions. On a desktop ⌘K "Ask <session>" types to a session (pm/palette.js). */
730
+ const composeInput = field({ label: PAL.compose.label, kind: 'textarea', hideLabel: true, placeholder: PAL.compose.placeholder,
731
+ attrs: { class: 'pl-compose__input', 'data-compose': 'input', rows: 1, enterkeyhint: 'send' } });
732
+ const composeSend = button({ label: PAL.compose.send, variant: 'primary', onClick: () => sendCompose(),
733
+ attrs: { 'data-compose': 'send', 'aria-label': PAL.compose.sendLabel } });
734
+ const composeLine = el('div', { class: 'pl-compose', 'data-compose': 'line' }, el('div', { class: 'pl-compose__field' }, composeInput), composeSend);
735
+ // Send follows the field as it is typed, not only on the next render of the bar
736
+ composeInput.control.addEventListener('input', () => { composeSend.disabled = !composeInput.control.value.trim(); onScratchInput(composeInput.control.value); });
737
+ /* The keyboard's Send key is Enter (enterkeyhint 'send'), so it sends. Shift+Enter, and Enter while a composition is open, stay a newline. */
738
+ composeInput.control.addEventListener('keydown', (e) => {
739
+ if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
740
+ e.preventDefault();
741
+ sendCompose();
742
+ });
743
+
744
+ /** Send the line to the session and clear it, only when the session took the text: otherwise the message stays and the PM is told. */
745
+ function sendCompose() {
746
+ const act = activeTab();
747
+ if (!act || act.kind !== 'term' || !(state.scratchText || '').trim()) return;
748
+ if (!sendScratchToTerminal({ strict: true })) { toast({ text: PAL.compose.failed(String(act.title || act.id)), tone: 'error' }); return; }
749
+ onScratchInput('');
750
+ }
751
+
752
+ /** The bar in real mode: the scratchpad toggle, the title and copy; a phone's terminal gets the compose line instead. */
753
+ function paintBottom(act) {
754
+ const compose = isMobile() && !!act && act.kind === 'term';
755
+ bottomTitle.textContent = act ? act.title : '';
756
+ if (compose) {
757
+ ensureComponentsCss();
758
+ const text = state.scratchText || '';
759
+ if (composeInput.control.value !== text) composeInput.control.value = text;
760
+ composeSend.disabled = !text.trim();
761
+ }
762
+ setChildren(bottomBar, compose ? [composeLine] : (act ? [bottomToggle, bottomTitle, bottomCopy] : [bottomToggle, bottomTitle]));
763
+ }
764
+
699
765
  /* ── clipboard + scratchpad actions (lines 2098–2118) ───────────────────────────────────────── */
700
766
 
701
767
  function copyToClipboard(txt) {
@@ -707,19 +773,23 @@ function activeTab() {
707
773
  return visibleTabs().find((t) => t.id === state.active) || null;
708
774
  }
709
775
 
710
- /** `⇥ send to terminal` (line 2104): one echoed prompt line per non-empty scratchpad line. */
711
- function sendScratchToTerminal() {
776
+ /** `⇥ send to terminal` (line 2104): one echoed prompt line per non-empty scratchpad line. Answers whether a real session took the text.
777
+ * `strict` (the compose line) sends first and echoes nothing when the session did not take it. */
778
+ function sendScratchToTerminal({ strict = false } = {}) {
712
779
  const act = activeTab();
713
- if (!act || act.kind !== 'term') return;
780
+ if (!act || act.kind !== 'term') return false;
714
781
  const v = (state.scratchText || '').trim();
715
- if (!v) return;
782
+ if (!v) return false;
716
783
  const lines = FX.scratchLines(v);
717
- setState((s) => ({ termHist: { ...s.termHist, [act.id]: [...(s.termHist[act.id] || []), ...lines] } }));
718
784
  /* A real PTY is not a scrollback list: when a terminal module is present it gets the same text
719
785
  * as keystrokes, so the paste lands in the shell rather than only in the transcript. */
786
+ let sent = false;
720
787
  if (!FIXTURE && termMod && typeof termMod.sendToTerm === 'function') {
721
- try { termMod.sendToTerm(act, v + '\n'); } catch { /* the transcript still shows it */ }
788
+ try { sent = termMod.sendToTerm(act, v + '\n') === true; } catch { /* the transcript still shows it */ }
722
789
  }
790
+ if (strict && !sent) return false;
791
+ setState((s) => ({ termHist: { ...s.termHist, [act.id]: [...(s.termHist[act.id] || []), ...lines] } }));
792
+ return sent;
723
793
  }
724
794
 
725
795
  /** `copy` (line 2111): the doc's text, or the attach command, or the scratchpad. */
@@ -809,7 +879,7 @@ function peerRow(prompt, p) {
809
879
  (p.agent === 'claude' && p.shown !== 'exited') ? el('button', {
810
880
  'data-r12': 'switch-model', title: 'types /model into this pane; effort is not changed',
811
881
  onClick: async () => {
812
- const t = typeof window !== 'undefined' && window.prompt ? window.prompt('Switch “' + p.name + '” to which tier? fast, balanced or deep', 'balanced') : null;
882
+ const t = await askChoice({ title: 'Switch “' + p.name + '” model', label: 'Tier', value: 'balanced', confirmLabel: 'Switch', options: [{ value: 'fast', label: 'fast' }, { value: 'balanced', label: 'balanced' }, { value: 'deep', label: 'deep' }] });
813
883
  if (!t || !t.trim()) return;
814
884
  const out = await fetch('/api/session/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, tier: t.trim() }) })
815
885
  .then((r) => r.json()).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
@@ -883,7 +953,7 @@ function startPromptBlock() {
883
953
  el('button', {
884
954
  onClick: () => newSession(prompt.slug, { force: true }),
885
955
  style: CH_BTN, hover: CHIP_HOVER,
886
- }, '+ start another session here anyway'),
956
+ }, ...iconLabel('plus', 'start another session here anyway')),
887
957
  el('button', { onClick: () => dismissStartPrompt(), style: CH_BTN, hover: CHIP_HOVER }, 'cancel'),
888
958
  ),
889
959
  // The limit, where the PM meets the feature — never only in a comment. PMOS hosts the terminal;
@@ -900,16 +970,21 @@ function startPromptBlock() {
900
970
  * `flex:1;overflow-y:auto` — and a flex item's automatic minimum is its content, so without this
901
971
  * the pane grows to fit a long document instead of letting the document scroll inside it, and the
902
972
  * growth escapes upward until the whole page scrolls. See the chain documented in pm/app.js. */
903
- const PANE_STYLE = 'flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
973
+ const PANE_STYLE = 'flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--border)';
904
974
  const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
905
975
 
906
976
  function paneFor(tab) {
907
977
  let host = paneHosts.get(tab.id);
908
978
  if (!host) { host = el('div', { style: PANE_STYLE }); paneHosts.set(tab.id, host); }
979
+ if (tab.kind === 'dest') {
980
+ // pm-os-absorb Runs 2–5: a destination as a tab until Run 3's frame. The registry arrives after boot (app.js
981
+ // imports it lazily and renders again), so until then the host stays empty.
982
+ const reg = window.pmApp && window.pmApp.registry;
983
+ if (reg && typeof reg.mountDestTab === 'function') reg.mountDestTab(host, tab);
984
+ return host;
985
+ }
909
986
  if (tab.kind === 'term') {
910
987
  if (termMod && typeof termMod.mountTerm === 'function') termMod.mountTerm(host, tab);
911
- } else if (tab.kind === 'task') {
912
- mountTask(host, tab);
913
988
  } else if (tab.kind === 'skill') {
914
989
  if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
915
990
  } else if (tab.kind === 'transcript') {
@@ -918,6 +993,9 @@ function paneFor(tab) {
918
993
  if (cockpitMod && typeof cockpitMod.mountView === 'function') cockpitMod.mountView(host, tab);
919
994
  } else if (tab.kind === 'web') {
920
995
  mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
996
+ } else if (tab.kind === 'breakdown') {
997
+ // pm-os-absorb Run 1 — an initiative's items (Tree, Board, item sheet); an empty pane until the module lands
998
+ if (breakdownMod && typeof breakdownMod.mountBreakdown === 'function') breakdownMod.mountBreakdown(host, tab);
921
999
  } else {
922
1000
  mountDoc(host, tab);
923
1001
  }
@@ -932,6 +1010,7 @@ function reapClosedPanes() {
932
1010
  const tab = { id };
933
1011
  if (termMod && typeof termMod.disposeTerm === 'function') { try { termMod.disposeTerm(tab); } catch { /* ignore */ } }
934
1012
  try { disposeDoc(id); } catch { /* ignore */ }
1013
+ try { if (window.pmApp && window.pmApp.registry) window.pmApp.registry.disposeDestTab(id); } catch { /* ignore */ }
935
1014
  if (host.parentNode) host.parentNode.removeChild(host);
936
1015
  paneHosts.delete(id);
937
1016
  const slot = offSlots.get(id);
@@ -997,6 +1076,37 @@ function keepSessionsLive(shown) {
997
1076
 
998
1077
  /* ── the tab bar ───────────────────────────────────────────────────────────────────────────── */
999
1078
 
1079
+ /* pm-os-absorb Run 3 (slice 3A): in the frame this panel is the agent dock. Its strip then carries the dock's own
1080
+ * "+ Session" menu (new session, session with options, and the skills to start from) in place of the one-click chip, and a
1081
+ * control that folds the dock to its rail. dock.js is loaded lazily, like the other siblings here, so panel 4 never waits
1082
+ * for it; until it lands the strip shows the chip it always had. Outside the frame (fixture mode, ?layout=legacy) nothing
1083
+ * here changes. */
1084
+ let dockMod = null;
1085
+ const inFrame = () => !!(window.pmApp && window.pmApp.frame && window.pmApp.frame.on);
1086
+ import('./dock.js').then((m) => { dockMod = m; if (inFrame()) setState({}); }).catch(() => { dockMod = null; });
1087
+ let dockParts = null;
1088
+ function frameDock() {
1089
+ if (!dockMod || !inFrame()) return null;
1090
+ const repo = currentProduct();
1091
+ const ws = repo && dataMod && typeof dataMod.wsOf === 'function' ? dataMod.wsOf(repo) : null;
1092
+ if (!dockParts || dockParts.ws !== ws) {
1093
+ dockParts = {
1094
+ ws,
1095
+ session: dockMod.sessionMenuButton({
1096
+ ws,
1097
+ newSession: () => newSession(),
1098
+ withOptions: (d) => { openNewSession(selSlug(), { agent: d.cli }); setState({ newSessTier: d.tier }); },
1099
+ openSkill: (sk) => openTab({ id: 'skill:' + sk.id, kind: 'skill', title: sk.label || sk.skill || sk.id, skillId: sk.id, repo: state.product || null, slug: null, role: 'SKILL' }),
1100
+ }),
1101
+ fold: dockParts ? dockParts.fold : dockMod.foldButton({ fold: () => window.pmApp.frame.foldDock() }),
1102
+ focus: dockParts ? dockParts.focus : dockMod.focusButton({ toggle: () => window.pmApp.frame.toggleFocus() }),
1103
+ };
1104
+ }
1105
+ if (isMobile()) return { session: dockParts.session, fold: null, focus: null };
1106
+ dockMod.paintFocus(dockParts.focus, !!window.pmApp.frame.dockMax);
1107
+ return { session: dockParts.session, fold: dockParts.fold, focus: dockParts.focus };
1108
+ }
1109
+
1000
1110
  /**
1001
1111
  * overhaul-r1 R2.2/R2.3 — the line of work a tab is on, or null when it is the main tree (which
1002
1112
  * shows nothing extra). A doc tab carries its tree from the search hit that opened it; a terminal
@@ -1026,7 +1136,7 @@ function cockpitTabNode(t, act, splitTab, vertical) {
1026
1136
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1027
1137
  const meta = t.kind === 'term'
1028
1138
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1029
- : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1139
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1030
1140
  const br = tabBranch(t);
1031
1141
  return el('div', {
1032
1142
  'data-tab': t.id, 'data-tab-active': front ? '1' : '0',
@@ -1054,7 +1164,7 @@ function cockpitTabNode(t, act, splitTab, vertical) {
1054
1164
  onClick: () => closeTab(t.id), 'data-tab-close': '', 'aria-label': 'close ' + t.title, title: 'close',
1055
1165
  style: 'color:var(--muted-foreground)' + (front ? '' : ';position:absolute;right:3px;top:50%;transform:translateY(-50%)'),
1056
1166
  hover: 'color:var(--foreground)',
1057
- }, '×'),
1167
+ }, closeIcon()),
1058
1168
  );
1059
1169
  }
1060
1170
 
@@ -1063,7 +1173,7 @@ function cockpitTabRow(t, front) {
1063
1173
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1064
1174
  const meta = t.kind === 'term'
1065
1175
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1066
- : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1176
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1067
1177
  const br = tabBranch(t);
1068
1178
  return el('div', {
1069
1179
  'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-tab-row': '',
@@ -1088,7 +1198,7 @@ function cockpitTabRow(t, front) {
1088
1198
  el('button', {
1089
1199
  onClick: () => closeTab(t.id), 'data-tab-close': '', 'aria-label': 'close ' + t.title, title: 'close',
1090
1200
  style: 'flex:none;color:var(--muted-foreground);padding:0 4px', hover: 'color:var(--foreground)',
1091
- }, '×'),
1201
+ }, closeIcon()),
1092
1202
  );
1093
1203
  }
1094
1204
 
@@ -1104,7 +1214,7 @@ function tabNode(t, act, splitTab) {
1104
1214
  * fixture render keeps the design's literal — the comparator reads this text node. */
1105
1215
  const meta = t.kind === 'term'
1106
1216
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1107
- : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1217
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1108
1218
 
1109
1219
  return el('div', {
1110
1220
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -1126,10 +1236,10 @@ function tabNode(t, act, splitTab) {
1126
1236
  ),
1127
1237
  ),
1128
1238
  el('button', {
1129
- onClick: () => closeTab(t.id),
1239
+ onClick: () => closeTab(t.id), 'aria-label': 'close ' + t.title, title: 'close',
1130
1240
  style: 'color:oklch(0.6 0.02 213);font-size:12px;padding:2px 3px;align-self:flex-start;margin-top:6px',
1131
1241
  hover: 'color:oklch(0.987 0.002 197.1)',
1132
- }, '×'),
1242
+ }, closeIcon()),
1133
1243
  );
1134
1244
  }
1135
1245
 
@@ -1153,16 +1263,19 @@ export function renderTabs(host) {
1153
1263
 
1154
1264
  // ── tab bar ──
1155
1265
  const canSplit = visTabs.length >= 2 || !!state.splitId;
1156
- splitLabel.textContent = state.splitId ? '⫽ unsplit' : '⫽ split';
1266
+ splitLabel.textContent = state.splitId ? 'unsplit' : 'split';
1157
1267
  const ck = cockpitLook();
1158
- newSessionBtn.textContent = ck ? '+ session' : '+ session';
1159
- emptyState.textContent = "nothing open — click a task's output or an artifact, or " + (ck ? '+' : '+') + ' session above';
1268
+ ensureSprite(); // the dock controls draw lucide icons (C6); the sprite loads once
1269
+ emptyState.textContent = 'Nothing is open. Open an artifact, or press + session to start a session.';
1160
1270
  /* The tabs are drawn anew on every paint, so the bar is empty for a moment and its scroll would snap back to the start: a
1161
1271
  * crowded strip (or a long list) scrolled by hand jumped back on the next repaint. The offset is kept across the redraw. */
1162
1272
  const keepX = tabBar.scrollLeft, keepY = tabBar.scrollTop;
1163
1273
  const vertical = ck && !!state.tabsVertical;
1274
+ const fr = frameDock(); // the dock's own controls, in the frame only
1164
1275
  host.style.flexDirection = vertical ? 'row' : 'column';
1165
- orientLabel.textContent = vertical ? '⇆ tabs on top' : '⇅ tabs at side';
1276
+ orientLabel.textContent = vertical ? 'tabs on top' : 'tabs at side';
1277
+ const oi = vertical ? 'arrow-left-right' : 'arrow-up-down';
1278
+ if (!orientIcon.firstChild || orientIcon.firstChild.getAttribute('data-icon') !== oi) orientIcon.replaceChildren(icon(oi));
1166
1279
  orientBtn.title = vertical ? 'show the tabs as a strip above the panes' : 'show the tabs as a list at the left of the panes';
1167
1280
  if (vertical) {
1168
1281
  tabBar.setAttribute('style', CK_VBAR);
@@ -1170,14 +1283,16 @@ export function renderTabs(host) {
1170
1283
  newSessionBtn.setAttribute('style', V_BTN + ';margin-bottom:6px');
1171
1284
  for (const b of [newWebBtn, newSessionOptsBtn, splitBtn, orientBtn]) b.setAttribute('style', V_BTN);
1172
1285
  setChildren(tabBar, [
1173
- newSessionBtn,
1286
+ fr ? fr.session : newSessionBtn,
1174
1287
  ...visTabs.map((t) => cockpitTabNode(t, act, splitTab, true)),
1175
1288
  el('div', { 'data-tabs': 'vcontrols', style: 'margin-top:auto;padding-top:8px;display:flex;flex-direction:column;gap:4px' },
1176
1289
  FIXTURE ? null : newWebBtn,
1177
1290
  !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1178
1291
  newSessionOptsBtn,
1179
1292
  canSplit ? splitBtn : null,
1180
- orientBtn),
1293
+ orientBtn,
1294
+ fr ? fr.focus : null,
1295
+ fr ? fr.fold : null),
1181
1296
  ]);
1182
1297
  } else if (ck) {
1183
1298
  tabBar.setAttribute('style', CK_BAR + ';scrollbar-width:thin');
@@ -1191,14 +1306,14 @@ export function renderTabs(host) {
1191
1306
  ].filter(Boolean);
1192
1307
  extras[0].style.marginLeft = 'auto';
1193
1308
  // The switch sits after `+ session`, so the strip still ends the mockup's way in the fixture, which does not draw it.
1194
- setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, newSessionBtn, FIXTURE ? null : orientBtn]);
1309
+ setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, fr ? fr.session : newSessionBtn, FIXTURE ? null : orientBtn, fr ? fr.focus : null, fr ? fr.fold : null]);
1195
1310
  } else {
1196
1311
  tabBar.setAttribute('style', BAR_STYLE);
1197
1312
  newSessionBtn.setAttribute('style', CHIP_STYLE);
1198
1313
  newWebBtn.setAttribute('style', CHIP_STYLE); newSessionOptsBtn.setAttribute('style', CHIP_STYLE); splitBtn.setAttribute('style', SPLIT_STYLE);
1199
1314
  setChildren(tabBar, [
1200
1315
  ...visTabs.map((t) => tabNode(t, act, splitTab)),
1201
- newSessionBtn,
1316
+ fr ? fr.session : newSessionBtn,
1202
1317
  FIXTURE ? null : newWebBtn,
1203
1318
  !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1204
1319
  newSessionOptsBtn,
@@ -1230,36 +1345,10 @@ export function renderTabs(host) {
1230
1345
  // …and the sessions that are open but not on screen keep running, off screen (see above).
1231
1346
  keepSessionsLive(new Set([act && act.id, splitTab && splitTab.id].filter(Boolean)));
1232
1347
 
1233
- // ── scratchpad ──
1234
- /* `defaultValue` IS the textarea's child text node — the rendered text lives in the DOM, not only
1235
- * in a property, which is how the prototype's runtime leaves it. Writing it costs a typed-in box
1236
- * nothing; `value` is written only when it differs, so the caret stays where the PM put it. */
1237
- const scratchText = state.scratchText || '';
1238
- if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
1239
- if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
1240
- /* The drawer's height, real mode only. The style ATTRIBUTE keeps the prototype's string; only the
1241
- * resolved `height` property is written, so the fixture render — which never reaches here — stays
1242
- * byte-identical to the design. Written on every paint rather than only on resize, because the
1243
- * restored value has to land on the first one. */
1244
- if (!FIXTURE) {
1245
- const h = clampScratchH(state.scratchH || SCRATCH_DEFAULT) + 'px';
1246
- if (scratchTa.style.height !== h) scratchTa.style.height = h;
1247
- }
1248
- scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
1249
- const canSend = !!(act && act.kind === 'term');
1250
- scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
1251
- scratchSendBtn.setAttribute('style', canSend ? PAD_BTN_PRIMARY : PAD_BTN_OFF);
1252
- scratchClearBtn.disabled = !scratchText;
1253
- scratchClearBtn.style.opacity = scratchText ? '' : '0.5';
1254
- /* The header names whose pad this is, and the note says what keeps it and what Send does. Both are
1255
- * read from the same facts `padKey` uses, so they cannot claim a pad the drawer is not showing. */
1256
- scratchHeadLabel.textContent = FIXTURE ? 'scratchpad'
1257
- : (canSend ? 'scratchpad · this session' : 'scratchpad · this repository');
1258
- scratchNote.textContent = FIXTURE ? 'Kept until the page closes.'
1259
- : (canSend ? 'Kept for this session. Send also presses Enter.' : 'Kept for this repository. Open a terminal to send.');
1348
+ // ── scratchpad (renderScratch owns the drawer and the toggle label; 3C) ──
1349
+ renderScratch(act);
1260
1350
 
1261
1351
  // ── bottom bar ──
1262
- scratchToggleLabel.textContent = state.scratchOpen ? '▼ scratchpad' : '▲ scratchpad';
1263
1352
  colTitleLabel.textContent = act ? act.title : 'scratchpad';
1264
1353
  copyLabel.textContent = state.copied ? 'copied ✓' : 'copy';
1265
1354
 
@@ -1276,7 +1365,7 @@ export function renderTabs(host) {
1276
1365
  newSessionBlock(),
1277
1366
  visTabs.length === 0 && !state.startPrompt && !state.newSess ? emptyState : null,
1278
1367
  panesRow,
1279
- state.scratchOpen ? scratchBlock : null,
1368
+ state.scratchOpen && (FIXTURE || !isMobile()) ? scratchBlock : null,
1280
1369
  bottomBar,
1281
1370
  ]);
1282
1371
  }
@@ -17,6 +17,7 @@ import { el } from './dom.js';
17
17
  import { state, setState, subscribe } from './state.js';
18
18
  import { boardOf } from './board-read.js';
19
19
  import { openNewSession } from './panel-newsess.js';
20
+ import { askConfirm } from './dialogs.js';
20
21
 
21
22
  const ip = (v) => el('span', null, v);
22
23
  const MONO = 'font-family:var(--mono);font-size:10.5px';
@@ -76,8 +77,10 @@ async function endSession(name, s) {
76
77
  }
77
78
 
78
79
  async function removeTree(name, t) {
79
- const ok = typeof window === 'undefined' || !window.confirm ? true
80
- : window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThis only removes the folder (git worktree remove). The branch is kept and nothing is merged into main — merge through a pull request. A folder with unsaved work, or a running session, is refused: end the session first.');
80
+ const ok = await askConfirm({
81
+ title: 'Remove the folder for “' + t.branch + '”?', confirmLabel: 'Remove folder', danger: true,
82
+ body: t.dir + '\n\nThis only removes the folder (git worktree remove). The branch is kept and nothing is merged into main — merge through a pull request. A folder with unsaved work, or a running session, is refused: end the session first.',
83
+ });
81
84
  if (!ok) return;
82
85
  const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
83
86
  if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }