@worca/app 1.2.0-rc.3 → 1.3.0-rc.2

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 (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3736 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1589 -212
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +447 -29
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1499 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1304 -194
@@ -0,0 +1,261 @@
1
+ // ui/public/getting-started.mjs
2
+ // The Getting-started checklist (docs/getting-started.md): the tile shelf on the
3
+ // New pipeline view, the sidebar pill that follows the user to other views, and
4
+ // the welcome dialog's bindings. Pure DOM in, pure DOM out — no fetch, no app
5
+ // state — so test/ui-getting-started.test.mjs drives it in jsdom without booting
6
+ // app.js. Completion is never decided here: `status.steps` is the server's word
7
+ // (GET /api/onboarding, src/core/onboarding.mjs), this module only paints it.
8
+
9
+ /** Shelf order = the arc: prerequisite → first object → see the loop → talk to
10
+ * it → know the workflows → real work → scale. Labels are outcomes, never settings. */
11
+ export const GETTING_STARTED_STEPS = Object.freeze([
12
+ { id: 'claude', label: 'Connect Claude Code', vig: 'claude' },
13
+ { id: 'project', label: 'Add your first project', vig: 'project' },
14
+ { id: 'run', label: 'Watch a run end to end', vig: 'run' },
15
+ { id: 'ask', label: 'Ask Worca about a run', vig: 'ask' },
16
+ { id: 'workflows', label: 'Explore the built-in workflows', vig: 'workflow' },
17
+ { id: 'realRun', label: 'Run a real pipeline', vig: 'realRun' },
18
+ { id: 'workspace', label: 'Group projects into a workspace', vig: 'workspace' },
19
+ { id: 'teamMetrics', label: 'Turn on team metrics', vig: 'teamMetrics' },
20
+ ]);
21
+
22
+ /** Seconds a tile holds the reveal sequence before the next arrives: an
23
+ * unfinished tile paints its drawing first, a finished one arrives already
24
+ * drawn and still, so it only needs its own fade. */
25
+ const TILE_HOLD = 0.32;
26
+ const DONE_HOLD = 0.1;
27
+
28
+ export const doneCount = (status) => GETTING_STARTED_STEPS.filter((s) => !!status?.steps?.[s.id]).length;
29
+ export const allStepsDone = (status) => doneCount(status) === GETTING_STARTED_STEPS.length;
30
+
31
+ /* ---------------------------------------------------------------- artwork */
32
+ // Each vignette is the app's own vocabulary — hollow nodes, wires, one accent
33
+ // element per scene (the violet "Worca-authored" hue) — drawn at 120×70 on the
34
+ // theme tokens so both schemes are right by construction. `--d` is the element's
35
+ // slot in the tile's own sequence; the tile's `--tile-delay` offsets all of them.
36
+ // Constant markup only (no user data reaches innerHTML).
37
+ const el = (tag, cls, d, attrs) => `<${tag} class="${cls}" style="--d:${d}s" ${attrs}/>`;
38
+ const node = (d, cx, cy, r = 6, extra = '') => el('circle', `gs-pop gs-node ${extra}`.trim(), d, `cx="${cx}" cy="${cy}" r="${r}"`);
39
+ const wire = (d, path, extra = '') => el('path', `gs-draw gs-wire ${extra}`.trim(), d, `pathLength="1" d="${path}"`);
40
+ const shape = (d, tag, cls, attrs) => el(tag, `gs-pop ${cls}`, d, attrs);
41
+
42
+ const VIGNETTES = {
43
+ // The terminal prompt, then the once-node lights up: the CLI is reachable.
44
+ claude: () => [
45
+ shape(0.05, 'rect', 'gs-frame', 'x="12" y="14" width="56" height="42" rx="7"'),
46
+ shape(0.3, 'path', 'gs-glyph', 'd="M24 28l8 7-8 7M36 42h12"'),
47
+ wire(0.5, 'M68 35 C 80 35, 86 35, 96 35'),
48
+ node(0.8, 102, 35, 6, 'gs-accent'),
49
+ ],
50
+ // A folder, then a node grows out of it.
51
+ project: () => [
52
+ shape(0.05, 'path', 'gs-frame', 'd="M14 22a4 4 0 0 1 4-4h12l6 6h22a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4z"'),
53
+ wire(0.35, 'M62 38 C 76 38, 84 30, 96 26'),
54
+ node(0.6, 102, 24, 6, 'gs-accent'),
55
+ ],
56
+ // The loop: plan → refine → implement → review, the review wire curling back.
57
+ run: () => [
58
+ node(0.05, 16, 35, 5),
59
+ wire(0.15, 'M22 35h18'),
60
+ node(0.3, 46, 35, 5),
61
+ wire(0.4, 'M52 35h18'),
62
+ node(0.55, 76, 35, 5),
63
+ wire(0.65, 'M82 35h18'),
64
+ node(0.8, 106, 35, 5, 'gs-accent'),
65
+ wire(0.9, 'M76 41 C 76 58, 46 58, 46 41'),
66
+ ],
67
+ // The same pipeline, but the last node carries a spark: real tokens spent.
68
+ realRun: () => [
69
+ node(0.05, 18, 35, 5),
70
+ wire(0.15, 'M24 35h16'),
71
+ node(0.3, 46, 35, 5),
72
+ wire(0.4, 'M52 35h16'),
73
+ node(0.55, 74, 35, 5),
74
+ wire(0.65, 'M80 35h14'),
75
+ shape(0.85, 'path', 'gs-spark gs-accent', 'd="M104 26l2.2 5.6 5.6 2.2-5.6 2.2-2.2 5.6-2.2-5.6-5.6-2.2 5.6-2.2z"'),
76
+ ],
77
+ // A speech bubble reaches a run's node.
78
+ ask: () => [
79
+ shape(0.05, 'path', 'gs-frame', 'd="M14 18h34a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7H28l-9 9v-9h-5a7 7 0 0 1-7-7V25a7 7 0 0 1 7-7z"'),
80
+ shape(0.25, 'path', 'gs-glyph', 'd="M24 31h18"'),
81
+ wire(0.45, 'M55 34 C 72 34, 82 40, 96 40'),
82
+ node(0.7, 102, 40, 6, 'gs-accent'),
83
+ ],
84
+ // A canvas: three nodes being wired by hand, the new wire in accent.
85
+ workflow: () => [
86
+ node(0.05, 22, 22, 6),
87
+ node(0.15, 22, 50, 6),
88
+ node(0.25, 64, 36, 6),
89
+ wire(0.4, 'M28 24 C 44 28, 50 32, 58 34'),
90
+ wire(0.5, 'M28 48 C 44 44, 50 40, 58 38'),
91
+ wire(0.65, 'M70 36 C 82 36, 88 36, 96 36', 'gs-accent'),
92
+ node(0.85, 102, 36, 6, 'gs-accent'),
93
+ ],
94
+ // Two project folders under one roof.
95
+ workspace: () => [
96
+ shape(0.05, 'rect', 'gs-frame', 'x="14" y="26" width="26" height="22" rx="5"'),
97
+ shape(0.15, 'rect', 'gs-frame', 'x="48" y="26" width="26" height="22" rx="5"'),
98
+ wire(0.35, 'M27 26 C 27 12, 61 12, 61 26'),
99
+ wire(0.5, 'M74 37 C 84 37, 90 37, 96 37'),
100
+ node(0.75, 102, 37, 6, 'gs-accent'),
101
+ ],
102
+ // A shared branch: three teammates' nodes feeding one line.
103
+ teamMetrics: () => [
104
+ node(0.05, 18, 18, 5),
105
+ node(0.15, 18, 35, 5),
106
+ node(0.25, 18, 52, 5),
107
+ wire(0.35, 'M24 18 C 44 18, 50 35, 64 35'),
108
+ wire(0.45, 'M24 35h40'),
109
+ wire(0.55, 'M24 52 C 44 52, 50 35, 64 35'),
110
+ wire(0.65, 'M64 35h30'),
111
+ shape(0.85, 'path', 'gs-chart gs-accent', 'd="M84 46l6-8 5 4 7-10"'),
112
+ ],
113
+ };
114
+
115
+ /**
116
+ * One tile's artwork. `animate` false paints the FINISHED drawing with no
117
+ * motion — a step already done should not ask for attention again.
118
+ * @param {Document} doc
119
+ * @param {string} id a GETTING_STARTED_STEPS vig key
120
+ * @param {{animate:boolean, delay:number}} o
121
+ */
122
+ export function vignette(doc, id, { animate = true, delay = 0 } = {}) {
123
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
124
+ svg.setAttribute('viewBox', '0 0 120 70');
125
+ svg.setAttribute('aria-hidden', 'true');
126
+ svg.setAttribute('class', `gs-vig-svg ${animate ? 'play' : 'static'}`);
127
+ svg.style.setProperty('--tile-delay', `${delay}s`);
128
+ const parts = (VIGNETTES[id] || (() => []))();
129
+ svg.innerHTML = parts.join('');
130
+ return svg;
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ shelf */
134
+
135
+ /**
136
+ * Paint the shelf into `host` (emptied first). Nothing is painted when the
137
+ * status is unusable or the user hid the checklist. Rows stay clickable after
138
+ * they are done, so any guide can be replayed.
139
+ * @param {Element} host
140
+ * @param {{steps?:Record<string,boolean>, hidden?:boolean}|null} status
141
+ * @param {{onStep?:(id:string)=>void, onHide?:()=>void, animate?:boolean, hideLabel?:string}} [handlers]
142
+ */
143
+ export function renderGettingStarted(host, status, { onStep, onHide, animate = true, hideLabel = 'Hide' } = {}) {
144
+ if (!host) return;
145
+ const doc = host.ownerDocument;
146
+ host.replaceChildren();
147
+ if (!status || !status.steps || typeof status.steps !== 'object' || status.hidden) { host.hidden = true; return; }
148
+ host.hidden = false;
149
+ const done = doneCount(status);
150
+ const total = GETTING_STARTED_STEPS.length;
151
+
152
+ const card = doc.createElement('section');
153
+ card.className = 'card gs-card';
154
+ card.setAttribute('role', 'complementary');
155
+ card.setAttribute('aria-label', 'Getting started');
156
+
157
+ const head = doc.createElement('div');
158
+ head.className = 'gs-head';
159
+ const h2 = doc.createElement('h2');
160
+ h2.textContent = done === total ? 'All set' : 'Your progress'; // the page's topbar already says Getting started
161
+ const progress = doc.createElement('span');
162
+ progress.className = 'gs-progress';
163
+ progress.textContent = `${done} of ${total}`;
164
+ const hide = doc.createElement('button');
165
+ hide.type = 'button';
166
+ hide.className = 'gs-hide';
167
+ hide.textContent = hideLabel;
168
+ hide.title = 'The sidebar entry; Settings › General › Getting started also opens this page';
169
+ hide.addEventListener('click', () => onHide && onHide());
170
+ head.append(h2, progress, hide);
171
+ card.appendChild(head);
172
+
173
+ const tiles = doc.createElement('div');
174
+ tiles.className = 'gs-tiles';
175
+ // Tiles arrive one at a time IN ORDER, so a new user reads one idea at a
176
+ // time instead of eight at once. A finished tile takes its turn but arrives
177
+ // already drawn and still, so it holds the sequence only briefly.
178
+ let at = 0;
179
+ for (const s of GETTING_STARTED_STEPS) {
180
+ const isDone = !!status.steps[s.id];
181
+ const delay = at;
182
+ at += isDone ? DONE_HOLD : TILE_HOLD;
183
+ const tile = doc.createElement('button');
184
+ tile.type = 'button';
185
+ tile.className = `gs-tile${animate ? ' reveal' : ''}${isDone ? ' done' : ''}`;
186
+ tile.dataset.step = s.id;
187
+ tile.style.setProperty('--tile-delay', `${animate ? delay : 0}s`);
188
+ tile.setAttribute('aria-label', `${s.label} — ${isDone ? 'done' : 'not done yet'}`);
189
+ const mark = doc.createElement('span');
190
+ mark.className = 'gs-mark';
191
+ mark.setAttribute('aria-hidden', 'true');
192
+ mark.textContent = '✓';
193
+ const vig = doc.createElement('span');
194
+ vig.className = 'gs-vig';
195
+ vig.appendChild(vignette(doc, s.vig, { animate: animate && !isDone, delay }));
196
+ const label = doc.createElement('span');
197
+ label.className = 'gs-label';
198
+ label.textContent = s.label;
199
+ tile.append(mark, vig, label);
200
+ tile.addEventListener('click', () => onStep && onStep(s.id));
201
+ tiles.appendChild(tile);
202
+ }
203
+ card.appendChild(tiles);
204
+ host.appendChild(card);
205
+ }
206
+
207
+ /* ------------------------------------------------------------------- pill */
208
+
209
+ /**
210
+ * The sidebar row under New pipeline: "Getting started · 2/8". It follows the
211
+ * user to every view (the guides leave the shelf behind) and vanishes once the
212
+ * checklist is hidden or complete — nothing left to nag about.
213
+ * @param {Element} host
214
+ * @param {object|null} status
215
+ * @param {() => void} onOpen
216
+ */
217
+ export function renderGettingStartedPill(host, status, onOpen) {
218
+ if (!host) return;
219
+ const doc = host.ownerDocument;
220
+ host.replaceChildren();
221
+ if (!status || !status.steps || status.hidden || allStepsDone(status)) { host.hidden = true; return; }
222
+ host.hidden = false;
223
+ const done = doneCount(status);
224
+ const total = GETTING_STARTED_STEPS.length;
225
+ const btn = doc.createElement('button');
226
+ btn.type = 'button';
227
+ btn.className = 'gs-pill';
228
+ btn.setAttribute('aria-label', `Getting started — ${done} of ${total} done`);
229
+ btn.title = 'Getting started';
230
+ btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12.5l4.5 4.5L20 6"></path></svg>';
231
+ const text = doc.createElement('span');
232
+ text.textContent = 'Getting started';
233
+ const count = doc.createElement('span');
234
+ count.className = 'nav-count n-grey gs-pill-count';
235
+ count.textContent = `${done}/${total}`;
236
+ btn.append(text, count);
237
+ btn.addEventListener('click', () => onOpen && onOpen());
238
+ host.appendChild(btn);
239
+ }
240
+
241
+ /* ---------------------------------------------------------------- welcome */
242
+
243
+ /**
244
+ * Wire the static welcome dialog (#welcome-modal in index.html): each door
245
+ * hands its step id to `onDoor`, Skip / the backdrop / Esc call `onSkip`.
246
+ * Returns a function that unbinds the Esc listener (the modal itself is reused).
247
+ * @param {Element} modal
248
+ * @param {{onDoor:(step:string)=>void, onSkip:()=>void, win?:Window}} h
249
+ */
250
+ export function bindWelcome(modal, { onDoor, onSkip, win }) {
251
+ if (!modal) return () => {};
252
+ const w = win || modal.ownerDocument.defaultView;
253
+ for (const door of modal.querySelectorAll('[data-door]')) {
254
+ door.addEventListener('click', () => onDoor(door.dataset.door));
255
+ }
256
+ modal.querySelector('.ob-skip')?.addEventListener('click', () => onSkip());
257
+ modal.addEventListener('click', (e) => { if (e.target === modal) onSkip(); });
258
+ const onKey = (e) => { if (e.key === 'Escape' && !modal.classList.contains('hidden')) onSkip(); };
259
+ w.document.addEventListener('keydown', onKey);
260
+ return () => w.document.removeEventListener('keydown', onKey);
261
+ }
@@ -14,7 +14,7 @@ import { createGraphView } from './view.mjs';
14
14
  import { renderPalette, applyFilter, FLOW_GROUP } from './palette.mjs';
15
15
  import { renderNodeInspector, renderWireInspector, renderEmptyInspector } from './inspector.mjs';
16
16
  import { renderSaveDialog, openDialog, closeDialog } from './save-dialog.mjs';
17
- import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, NODE_W, snap }
17
+ import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, ZOOM_STEP, NODE_W, snap }
18
18
  from '../../../src/shared/graph/geometry.mjs';
19
19
  import { hitRoute } from '../../../src/shared/graph/route.mjs';
20
20
  import { canWire, newNode, newWire, normalizeTemplate, serializeTemplate }
@@ -28,6 +28,10 @@ export const UNDO_LIMIT = 50;
28
28
  * the composer and app.js share one literal (the saved list hides the ×, the
29
29
  * Save dialog prefills a copy name). */
30
30
  export const RESERVED_WORKFLOW_ID = 'wf_default';
31
+ /** Every built-in the server lists and refuses to delete: wf_default + the Memory defragment
32
+ * workflow. Both open read-only here (Save is Save-a-copy). wf_auto never reaches the composer. */
33
+ export const RESERVED_WORKFLOW_IDS = new Set([RESERVED_WORKFLOW_ID, 'wf_memory_defrag']);
34
+ export const isReservedWorkflowId = (id) => RESERVED_WORKFLOW_IDS.has(id);
31
35
  export const INSPECTOR_KEY = 'worca.composer.inspector';
32
36
  export const TAB_KEY = 'worca.composer.tab';
33
37
  export const TABS = Object.freeze(['agents', 'info']);
@@ -65,7 +69,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
65
69
 
66
70
  const view = createGraphView(hostEls.canvas, {
67
71
  doc, mode: 'edit', portsFn, agents, viewport,
68
- zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX, wheelPan: 'always',
72
+ zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX,
69
73
  });
70
74
  const stage = view.stage;
71
75
 
@@ -369,11 +373,15 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
369
373
  const onRefresh = () => readRect();
370
374
  const onNewCanvas = () => composer.newCanvas();
371
375
  const onSaveClick = () => openSaveDialog();
376
+ const onZoomIn = () => zoomStep(ZOOM_STEP);
377
+ const onZoomOut = () => zoomStep(1 / ZOOM_STEP);
378
+ const onCenterClick = () => fit();
372
379
 
373
380
  function zoomAbout(zNext, sx, sy) {
374
381
  const t = T();
375
382
  const z2 = clamp(zNext, ZOOM_MIN, ZOOM_MAX);
376
383
  view.setTransform({ x: sx - ((sx - t.x) / t.z) * z2, y: sy - ((sy - t.y) / t.z) * z2, z: z2 });
384
+ paintNav();
377
385
  }
378
386
 
379
387
  function onWheel(ev) {
@@ -395,6 +403,27 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
395
403
  }
396
404
  function fit(opts = {}) {
397
405
  view.fit({ insetRight: opts.insetRight == null ? insetRight() : opts.insetRight, pad: 60 });
406
+ paintNav();
407
+ }
408
+ /** Stage-local centre of the band the rail leaves visible — the point the
409
+ * buttons zoom about and centre to. Reads the rect itself: the rail may have
410
+ * been collapsed since the last gesture. */
411
+ function bandCenter() {
412
+ readRect();
413
+ return { x: (R.width - insetRight()) / 2, y: R.height / 2 };
414
+ }
415
+ /** One discrete zoom press. zoomAbout owns the 0.4..1.6 clamp. */
416
+ function zoomStep(mult) {
417
+ const c = bandCenter();
418
+ zoomAbout(T().z * mult, c.x, c.y);
419
+ }
420
+ /** The cluster's only state: a button that cannot move is disabled. Guarded
421
+ * per element — every headless caller (unit tests, the CDP probe) may hand us
422
+ * hostEls without a cluster at all. */
423
+ function paintNav() {
424
+ const z = T().z;
425
+ if (hostEls.zoomIn) hostEls.zoomIn.disabled = z >= ZOOM_MAX - 1e-9;
426
+ if (hostEls.zoomOut) hostEls.zoomOut.disabled = z <= ZOOM_MIN + 1e-9;
398
427
  }
399
428
  // `autoLayout(tpl, portsFn)` returns a POSITION MAP `{ [nodeId]: {x, y} }` — not
400
429
  // a template, not `{nodes}` (verified 2026-08-27 against src/shared/graph/layout.mjs,
@@ -677,7 +706,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
677
706
  * @returns {{copy: boolean, plugin: string}} */
678
707
  function saveMode(saveAs) {
679
708
  const plugin = pluginOriginName(tplOrigin);
680
- return { copy: Boolean(saveAs) || tpl.id === RESERVED_WORKFLOW_ID || Boolean(plugin), plugin };
709
+ return { copy: Boolean(saveAs) || isReservedWorkflowId(tpl.id) || Boolean(plugin), plugin };
681
710
  }
682
711
 
683
712
  function openSaveDialog({ saveAs = false } = {}) {
@@ -713,7 +742,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
713
742
  // Save on a LOADED row sends its id; a copy omits it so the server mints
714
743
  // wf_${slugify(name)}. `dataset.saveAs` already carries saveMode()'s verdict,
715
744
  // so the reserved built-in and every plugin-owned row land here as copies.
716
- if (!saveAs && tpl.id && tpl.id !== RESERVED_WORKFLOW_ID) body.id = tpl.id;
745
+ if (!saveAs && tpl.id && !isReservedWorkflowId(tpl.id)) body.id = tpl.id;
717
746
  else delete body.id;
718
747
  try {
719
748
  const res = await api.saveWorkflow(body);
@@ -786,6 +815,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
786
815
  hostEls.insToggle?.addEventListener('click', onRailToggle);
787
816
  hostEls.insTabs?.addEventListener('click', onTabClick);
788
817
  hostEls.saveBtn?.addEventListener('click', onSaveClick);
818
+ hostEls.zoomIn?.addEventListener('click', onZoomIn);
819
+ hostEls.zoomOut?.addEventListener('click', onZoomOut);
820
+ hostEls.centerBtn?.addEventListener('click', onCenterClick);
789
821
  setRail(readKey(INSPECTOR_KEY) !== 'collapsed');
790
822
  setTab(readKey(TAB_KEY) || TABS[0]);
791
823
  // The model/effort lists are chrome, not graph state: pull them once through
@@ -803,6 +835,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
803
835
  ro.observe(stage);
804
836
  }
805
837
  readRect();
838
+ paintNav();
806
839
  return composer;
807
840
  }
808
841
  let ro = null;
@@ -822,6 +855,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
822
855
  hostEls.insToggle?.removeEventListener('click', onRailToggle);
823
856
  hostEls.insTabs?.removeEventListener('click', onTabClick);
824
857
  hostEls.saveBtn?.removeEventListener('click', onSaveClick);
858
+ hostEls.zoomIn?.removeEventListener('click', onZoomIn);
859
+ hostEls.zoomOut?.removeEventListener('click', onZoomOut);
860
+ hostEls.centerBtn?.removeEventListener('click', onCenterClick);
825
861
  endPalDrag();
826
862
  stage.removeEventListener('pointermove', onMove);
827
863
  stage.removeEventListener('pointerup', onUp);
@@ -866,7 +902,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
866
902
  const composer = {
867
903
  view, stats, hooks,
868
904
  mount, destroy, resume, suspend, commit, loadTemplate,
869
- fit, autoLayout: runAutoLayout, zoomAbout, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
905
+ fit, autoLayout: runAutoLayout, zoomAbout, zoomStep, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
870
906
  spawn, paintPalette, paintInspector,
871
907
  openSaveDialog, setSavedDomains(list) { savedDomains = list || []; },
872
908
  setModels(cfg) { modelsSet = true; applyModels(cfg || {}); },
@@ -31,7 +31,9 @@ function select(doc, cls, name, label, items, value, { disabled = false, title =
31
31
  if (opt.value === (value == null ? '' : String(value))) o.selected = true;
32
32
  sel.appendChild(o);
33
33
  }
34
- wrap.appendChild(sel);
34
+ const shell = h(doc, 'span', 'ins-select-wrap'); // the product's .select-wrap idea: the chevron is a token-coloured ::after on a wrapper
35
+ shell.appendChild(sel);
36
+ wrap.appendChild(shell);
35
37
  return wrap;
36
38
  }
37
39
  function toggle(doc, cls, name, label, hint, { checked = false, disabled = false, title = '' } = {}) {
@@ -35,3 +35,4 @@ export {
35
35
  TEMPLATE_VERSION, KINDS, FLOW_KINDS, PORT_TYPES, AWAIT_PORT, TASK_PORTS, END_PORTS,
36
36
  gatePorts, DEFAULT_MAX_CYCLES, MAX_PORTS_PER_SIDE, LIMITS, NODE_ID_RE, WIRE_ID_RE, PORT_ID_RE,
37
37
  } from '../../../src/shared/graph/constants.mjs';
38
+ export { flowOrder, flowPerRow, flowLayout, flowAnchors, routeFlow, simplifyOrtho, FLOW_SCALE, FLOW_DEFAULT_WIDTH, FLOW_RADIUS } from '../../../src/shared/graph/flow-layout.mjs';
@@ -12,12 +12,24 @@
12
12
  // with the footer rows the view actually painted (never a second band count).
13
13
  import { createGraphView } from './view.mjs';
14
14
  import { applyDecor, manifestAgents, manifestNodes, manifestPortsFn, manifestTemplate } from './run-decor.mjs';
15
- import { fitBounds } from '../../../src/shared/graph/geometry.mjs';
15
+ import { fitBounds, ZOOM_STEP } from '../../../src/shared/graph/geometry.mjs';
16
16
 
17
17
  export const STATIC_HOST_H = 300; // D5: the Running list card's graph height
18
18
  export const STATIC_INSET = 32; // wrap padding allowance on both axes (16 each side)
19
19
  export const DETAIL_MIN_H = 360, DETAIL_MAX_H = 600, DETAIL_PAD_H = 48;
20
- export const HINT_TEXT = 'click to pan · ⌘+scroll to zoom';
20
+ export const HINT_TEXT = 'drag to pan · ⌘/ctrl+scroll to zoom';
21
+ /** px of monitor canvas the nav cluster owns: 30px button + 12px inset + 12px air.
22
+ * The composer reserves `insetRight` for its rail for exactly this reason — the
23
+ * cluster is opaque and clickable, and an auto-fit that ignores it parks the
24
+ * bottom-right card's .xtoggle strip underneath. */
25
+ export const NAV_INSET = 54;
26
+ /** The cluster's three buttons: [data-nav, label, glyph]. The glyphs are the
27
+ * composer's (ui/public/index.html #gv-nav) so both canvases read identically. */
28
+ const NAV_BTNS = [
29
+ ['in', 'Zoom in', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke-linecap="round"></path></svg>'],
30
+ ['out', 'Zoom out', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M5 12h14" stroke-linecap="round"></path></svg>'],
31
+ ['center', 'Fit graph to view', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4.5" y="4.5" width="15" height="15" rx="2.5"></rect><circle cx="12" cy="12" r="2.6" fill="currentColor" stroke="none"></circle></svg>'],
32
+ ];
21
33
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
22
34
 
23
35
  /** Centre `b` (already padded) in a vw×vh viewport through the SHARED `fitBounds`;
@@ -39,7 +51,7 @@ export function mountRunGraph(hostEl, opts = {}) {
39
51
  const isStatic = mode === 'static';
40
52
  const zoomMin = 0.3, zoomMax = isStatic ? 1 : 1.6;
41
53
 
42
- let view = null, stepper = null, decor = null, runId = null, expanded = null, ro = null, lastFit = null, bound = false;
54
+ let view = null, stepper = null, decor = null, runId = null, expanded = null, ro = null, lastFit = null, bound = false, nav = null;
43
55
  // The last width `view.readRect()` reported. A host inside a `display:none`
44
56
  // subtree (compact density, a closed detail) measures 0×0, and both fitters
45
57
  // BAIL on that rather than poison the transform / --run-host-h with it; the
@@ -92,17 +104,64 @@ export function mountRunGraph(hostEl, opts = {}) {
92
104
  if (!(r.width > 0)) return; // hidden host (0×0): a 0-width fit would
93
105
  const b = view.bounds(24); // pin --run-host-h at the 360px floor and
94
106
  if (!b) return; // zoom to the 0.3 clamp for good.
95
- const vw = Math.max(1, r.width);
107
+ const vw = Math.max(1, r.width - NAV_INSET); // the cluster is opaque and clickable
96
108
  const zw = clamp(Math.min(vw / b.w, 1), zoomMin, 1);
97
109
  const hostH = clamp(Math.round((b.h - 48) * zw + DETAIL_PAD_H), DETAIL_MIN_H, DETAIL_MAX_H); // b is padded 24 each side
98
110
  wrap.style.setProperty('--run-host-h', `${hostH}px`);
99
111
  if (applyTransform) view.setTransform(fitInto(b, vw, hostH, { zoomMin }));
100
112
  }
101
113
 
114
+ /** Stage-local centre of the host box — the point the buttons zoom about.
115
+ * Reads the rect itself: the host may have resized since the last fit. */
116
+ function hostCenter() {
117
+ const r = view.readRect();
118
+ return { x: (r.width || 0) / 2, y: (r.height || 0) / 2 };
119
+ }
120
+ /** One discrete zoom press; view.zoomAbout owns the zoomMin..zoomMax clamp. */
121
+ function zoomStep(mult) {
122
+ if (!view) return;
123
+ const c = hostCenter();
124
+ view.zoomAbout(view.getTransform().z * mult, c.x, c.y);
125
+ paintNav();
126
+ }
127
+ /** The cluster's only state: a button that cannot move is disabled. Guarded —
128
+ * a static host never builds a cluster, and destroy() drops it. */
129
+ function paintNav() {
130
+ if (!nav || !view) return;
131
+ const z = view.getTransform().z;
132
+ nav.querySelector('[data-nav="in"]').disabled = z >= zoomMax - 1e-9;
133
+ nav.querySelector('[data-nav="out"]').disabled = z <= zoomMin + 1e-9;
134
+ }
135
+ function buildNav() {
136
+ nav = doc.createElement('div');
137
+ nav.className = 'gv-nav';
138
+ for (const [key, label, glyph] of NAV_BTNS) {
139
+ const b = doc.createElement('button');
140
+ b.type = 'button';
141
+ b.className = 'gv-nav-btn';
142
+ b.dataset.nav = key;
143
+ b.setAttribute('aria-label', label);
144
+ b.title = label;
145
+ b.innerHTML = glyph; // a literal from this module, never run data
146
+ nav.appendChild(b);
147
+ }
148
+ // A SIBLING of the stage, like .rg-hint: a press on a button can never reach
149
+ // the drag pipeline (the rule the composer's own cluster follows).
150
+ wrap.appendChild(nav);
151
+ on(nav, 'click', (e) => {
152
+ const btn = e.target.closest && e.target.closest('.gv-nav-btn');
153
+ if (!btn) return;
154
+ if (btn.dataset.nav === 'in') zoomStep(ZOOM_STEP);
155
+ else if (btn.dataset.nav === 'out') zoomStep(1 / ZOOM_STEP);
156
+ else fit(); // Center = zoom-to-fit, and it re-arms the auto re-fit
157
+ });
158
+ }
159
+
102
160
  function fit() {
103
161
  if (!view || !stepper) return;
104
162
  if (isStatic) fitStatic(); else fitMonitor(true);
105
163
  lastFit = view.getTransform();
164
+ paintNav();
106
165
  }
107
166
  /** The size pass without the transform (monitor only; static hosts never grow). */
108
167
  function sizeHost() {
@@ -124,12 +183,11 @@ export function mountRunGraph(hostEl, opts = {}) {
124
183
  hostEl.classList.add('gv-host');
125
184
  wrap.classList.add('gv-wrap', isStatic ? 'gv-wrap-static' : 'gv-wrap-monitor');
126
185
  view = createGraphView(hostEl, { mode, doc, raf, viewport,
127
- portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), zoomMin, zoomMax,
128
- wheelPan: isStatic ? 'always' : 'engaged' });
129
- // The view never auto-binds a nav: monitor hosts ask for one (D8 engaged-only)
130
- // and learn engagement from it — run-hosts keeps NO engagement state of its own,
131
- // and view.destroy() destroys every nav, so the handle is not kept.
132
- if (!isStatic) view.createNav({ wheelPan: 'engaged', onEngaged: (engaged) => wrap.classList.toggle('rg-engaged', engaged) });
186
+ portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), zoomMin, zoomMax });
187
+ // The view never auto-binds a nav: monitor hosts ask for one. There is no
188
+ // engagement state any more — a plain wheel is always the page's (D2) — and
189
+ // onTransform is how a wheel zoom or a drag repaints the cluster.
190
+ if (!isStatic) view.createNav({ onTransform: paintNav });
133
191
  }
134
192
 
135
193
  function update(nextRunId, nextStepper, nextDecor) {
@@ -158,7 +216,9 @@ export function mountRunGraph(hostEl, opts = {}) {
158
216
  hint.className = 'rg-hint';
159
217
  hint.textContent = HINT_TEXT;
160
218
  wrap.appendChild(hint);
161
- // (engage/disengage listeners live in the view's nav — see mount() above)
219
+ buildNav();
220
+ paintNav();
221
+ // (the wheel + drag gestures live in the view's nav — see mount() above)
162
222
  on(hostEl, 'click', (e) => {
163
223
  const toggle = e.target.closest && e.target.closest('.xtoggle');
164
224
  if (toggle) { expanded = expanded === toggle.dataset.nodeId ? null : toggle.dataset.nodeId; paint(); refit(); return; }
@@ -183,9 +243,10 @@ export function mountRunGraph(hostEl, opts = {}) {
183
243
  if (ro) { try { ro.disconnect(); } catch { /* jsdom */ } ro = null; }
184
244
  const hint = wrap.querySelector(':scope > .rg-hint');
185
245
  if (hint) hint.remove();
246
+ if (nav) { nav.remove(); nav = null; }
186
247
  if (view) { view.destroy(); view = null; } // view.destroy() tears every nav down
187
248
  hostEl.classList.remove('gv-host');
188
- wrap.classList.remove('gv-wrap', 'gv-wrap-static', 'gv-wrap-monitor', 'rg-engaged');
249
+ wrap.classList.remove('gv-wrap', 'gv-wrap-static', 'gv-wrap-monitor');
189
250
  wrap.style.removeProperty('--run-host-h');
190
251
  hostEl.style.width = '';
191
252
  hostEl.innerHTML = '';