@worca/app 1.2.0 → 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 +3715 -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 +1359 -214
  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 +423 -25
  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 +1450 -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 +1282 -193
@@ -17,9 +17,10 @@
17
17
  // graphBounds/fitBounds are imported HERE even though only Task 3 calls them:
18
18
  // this is the file's one geometry import and Task 3 appends code, not imports.
19
19
  import {
20
- NODE_W, ZOOM_MIN, ZOOM_MAX,
20
+ ZOOM_MIN, ZOOM_MAX, ZOOM_K,
21
21
  injectGeometry, nodeSize, portAnchor, graphBounds, fitBounds,
22
22
  } from '../../../src/shared/graph/geometry.mjs';
23
+ import { flowLayout, flowAnchors, routeFlow, FLOW_DEFAULT_WIDTH, FLOW_RADIUS } from '../../../src/shared/graph/flow-layout.mjs';
23
24
  import { routeAll, routeWire, routePathD, routeMid } from '../../../src/shared/graph/route.mjs';
24
25
  import { portsOf, resolveOrOutType } from '../../../src/shared/graph/ports.mjs';
25
26
  import { classifyLoops } from '../../../src/shared/graph/loops.mjs';
@@ -31,6 +32,10 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
31
32
  /** Legend copy is NORMATIVE (spec §7.1); the agents card header row renders it. */
32
33
  export const LEGEND_TEXT = 'grey = data · amber = loop · ◆ = conditional · ○ = gate · ⤫N = fan-out';
33
34
  export const FANOUT_GLYPH = '⤫';
35
+ /** px a press must travel before it becomes a PAN rather than a click. The run
36
+ * canvas delegates row/gate/result clicks off the same stage, so the threshold
37
+ * is what keeps a shaky click from stealing them. */
38
+ export const DRAG_PX = 4;
34
39
 
35
40
  /** Per-mode zoom clamps (§7.6). `edit` uses the geometry defaults. */
36
41
  export const MODE_ZOOM = {
@@ -84,7 +89,10 @@ export function createGraphView(host, {
84
89
  viewport = null,
85
90
  zoomMin = null,
86
91
  zoomMax = null,
87
- wheelPan = 'always',
92
+ scale = 1, // geometry multiplier (A1): every --gv-* length × scale, fonts floor at 9px in CSS
93
+ layout = 'auto', // 'auto' = the template's x/y (today) · 'flow' = rows in dispatch order (flow-layout.mjs)
94
+ band = null, // (node) => {model, effort, flags:[{text, cls?, title?}]} | null — the chip band under agent heads
95
+ order = null, // flow only: agent ids in dispatch order (a host may pass the proposal's `order[]`)
88
96
  } = {}) {
89
97
  const win = doc.defaultView || globalThis;
90
98
  const clamps = MODE_ZOOM[mode] || MODE_ZOOM.edit;
@@ -92,9 +100,16 @@ export function createGraphView(host, {
92
100
  const zMax = zoomMax == null ? clamps.max : zoomMax;
93
101
  const schedule = raf || ((fn) => (win.requestAnimationFrame ? win.requestAnimationFrame(fn) : setTimeout(fn, 16)));
94
102
  let agents = agentsIn || {};
103
+ const S = Number(scale) > 0 ? Number(scale) : 1;
104
+ const isFlow = layout === 'flow';
105
+ const hasBand = typeof band === 'function';
106
+ let bandOverride = null; // Map(nodeId -> band data) set by setBands(); wins over band(node)
107
+ let source = null; // the caller's template (flow re-lays it out on every render; never mutated)
108
+ let flowLay = null; // last flowLayout() result (flow only)
109
+ let flowWidth = 0; // last known host width (flow only; 0 => FLOW_DEFAULT_WIDTH)
95
110
 
96
111
  const stage = doc.createElement('div');
97
- stage.className = `gv-stage gv-${mode}`;
112
+ stage.className = `gv-stage gv-${mode}${isFlow ? ' gv-flow' : ''}`;
98
113
  stage.setAttribute('tabindex', '0');
99
114
  stage.setAttribute('aria-label', 'pipeline canvas');
100
115
  const world = doc.createElement('div');
@@ -112,7 +127,8 @@ export function createGraphView(host, {
112
127
  // Never replaceChildren(host): `.gv-chip` and `.gv-ins-rail` are the stage's
113
128
  // SIBLINGS inside the same canvas host and must survive a (re)mount.
114
129
  host.prepend(stage);
115
- injectGeometry(stage);
130
+ injectGeometry(stage, S);
131
+ const geo = { band: hasBand, scale: S };
116
132
 
117
133
  const nodeEls = new Map(); // nodeId -> card element
118
134
  const wireEls = new Map(); // wireId -> path element
@@ -140,7 +156,7 @@ export function createGraphView(host, {
140
156
  return n;
141
157
  };
142
158
  const portsAt = (node) => portsOf(portsFn, node) || { inputs: [], outputs: [] };
143
- const sizeOf = (node) => nodeSize(node, portsAt(node), { footerRows: footers.get(node.id) || 0 });
159
+ const sizeOf = (node) => nodeSize(node, portsAt(node), { footerRows: footers.get(node.id) || 0, ...geo });
144
160
 
145
161
  // ------------------------------------------------------------ wire routing
146
162
  // Cards are the router's OBSTACLES, so every repaint derives the wire shapes
@@ -159,6 +175,12 @@ export function createGraphView(host, {
159
175
  * the canonical full pass (D15). Also refreshes lastRect for every node. */
160
176
  function reroute(dirty = null) {
161
177
  if (!ctx || !current) return;
178
+ if (isFlow) {
179
+ // The flow router only needs the laid-out rows — no obstacles, no A*. Raw portsFn (A27).
180
+ routesBag = routeFlow(flowAnchors(current, portsFn, flowLay), flowLay);
181
+ for (const n of current.nodes) if (isNodeObj(n)) lastRect.set(n.id, rectOf(n));
182
+ return;
183
+ }
162
184
  const list = [];
163
185
  for (const w of current.wires) {
164
186
  if (!w || !w.from || !w.to) continue;
@@ -291,7 +313,9 @@ export function createGraphView(host, {
291
313
  const res = h('div', 'xresult'); // kind: 'result'
292
314
  if (!band.path) { res.textContent = band.text || ''; return res; }
293
315
  const a = h('a', null, band.text || '');
294
- a.href = '#'; a.dataset.path = band.path; a.title = band.path;
316
+ // draggable=false: Chrome drags an <a href> natively, which pointercancels a
317
+ // pan that started on it (D16). The click stays delegated to run-hosts.
318
+ a.href = '#'; a.draggable = false; a.dataset.path = band.path; a.title = band.path;
295
319
  res.appendChild(a);
296
320
  return res;
297
321
  }
@@ -359,6 +383,34 @@ export function createGraphView(host, {
359
383
  return true;
360
384
  }
361
385
 
386
+ const bandDataOf = (node) => (bandOverride && bandOverride.has(node.id) ? bandOverride.get(node.id) : (hasBand ? band(node) : null));
387
+ // Separated: an unseparated join lets {model:'Opus', effort:'5'} and {model:'Opus5', effort:''}
388
+ // share a signature, and paintBand early-returns on an equal one — stale chips after setBands.
389
+ const bandSig = (b) => (b ? [b.model || '', b.effort || '', b.pick ? 'pick' : '', ...(b.flags || []).map((f) => `${f.text}|${f.cls || ''}`)].join('\u0001') : '');
390
+ /** The chip band: model · effort · flags, one BAND_H×s row between .nhead and .nbody (agents only).
391
+ * `pick` (the chat card's proposed state, P3) makes the model/effort chips real buttons the host's delegated
392
+ * click opens a picker for; flags stay inert. Listeners never live here: replaceChildren would drop them. */
393
+ function paintBand(el, node) {
394
+ let nb = el.querySelector(':scope > .nband');
395
+ if (!hasBand || node.kind !== 'agent') { if (nb) nb.remove(); return; }
396
+ const data = bandDataOf(node) || { model: '', effort: '', flags: [] };
397
+ const sig = bandSig(data);
398
+ if (nb && nb.dataset.sig === sig) return;
399
+ if (!nb) { nb = h('div', 'nband'); el.insertBefore(nb, el.querySelector(':scope > .nbody')); }
400
+ nb.dataset.sig = sig;
401
+ const pick = !!data.pick;
402
+ const chip = (cls, text, which, title) => {
403
+ const c = h(pick ? 'button' : 'span', cls, text);
404
+ c.title = title;
405
+ if (pick) { c.type = 'button'; c.dataset.chip = which; c.setAttribute('aria-haspopup', 'menu'); c.setAttribute('aria-expanded', 'false'); }
406
+ return c;
407
+ };
408
+ const kids = [chip(`bchip model${data.model ? '' : ' is-unset'}`, data.model || 'default', 'model', data.model ? `model: ${data.model}` : 'model: the CLI default')];
409
+ if (data.effort || pick) kids.push(chip('bchip effort', data.effort || 'effort', 'effort', data.effort ? `effort: ${data.effort}` : 'effort: pick one'));
410
+ for (const f of data.flags || []) { const c = h('span', `bchip flag${f.cls ? ` ${f.cls}` : ''}`, f.text); c.title = f.title || f.text; kids.push(c); }
411
+ nb.replaceChildren(...kids);
412
+ }
413
+
362
414
  function paintCard(el, node) {
363
415
  const p = portsAt(node);
364
416
  const orType = node.kind === 'or' ? resolveOrOutType(current, portsFn, node.id, new Set()) : null;
@@ -370,8 +422,9 @@ export function createGraphView(host, {
370
422
  el.classList.add('node', `node-${node.kind}`);
371
423
  el.dataset.kind = node.kind;
372
424
  }
373
- el.style.width = `${NODE_W}px`;
374
- el.style.height = `${sizeOf(node).h}px`;
425
+ const box = sizeOf(node);
426
+ el.style.width = `${box.w}px`; // inline width beats the CSS var (a scaled host)
427
+ el.style.height = `${box.h}px`;
375
428
  const head = el.querySelector(':scope > .nhead');
376
429
  const hd = headerOf(node);
377
430
  const sig = `${hd.cls}|${hd.title}|${hd.icon}`;
@@ -383,8 +436,11 @@ export function createGraphView(host, {
383
436
  icon.setAttribute('fill', 'none');
384
437
  icon.setAttribute('stroke', 'currentColor');
385
438
  icon.innerHTML = hd.icon;
386
- head.replaceChildren(icon, h('span', 'tt', hd.title));
439
+ const tt = h('span', 'tt', hd.title);
440
+ tt.title = hd.title; // A35: an ellipsised name keeps its tooltip
441
+ head.replaceChildren(icon, tt);
387
442
  }
443
+ paintBand(el, node);
388
444
  paintBody(el, node, p, orType, awaitWired);
389
445
  placeCard(node);
390
446
  }
@@ -400,7 +456,7 @@ export function createGraphView(host, {
400
456
 
401
457
  const anchorOf = (end, dir) => {
402
458
  const node = ctx.byId.get(end.node);
403
- return node ? portAnchor(node, portsAt(node), end.port, dir) : null;
459
+ return node ? portAnchor(node, portsAt(node), end.port, dir, geo) : null;
404
460
  };
405
461
 
406
462
  /** Writes `d` only when the cached string differs — the whole point of the cache. */
@@ -409,7 +465,7 @@ export function createGraphView(host, {
409
465
  if (!path) return;
410
466
  const pts = routesBag.routes.get(wireId);
411
467
  if (!pts) return; // dangling endpoint paints nothing, never NaN
412
- const d = routePathD(pts);
468
+ const d = routePathD(pts, isFlow ? FLOW_RADIUS : undefined);
413
469
  if (dCache.get(wireId) !== d) {
414
470
  dCache.set(wireId, d);
415
471
  path.setAttribute('d', d);
@@ -417,7 +473,7 @@ export function createGraphView(host, {
417
473
  }
418
474
  const badge = badgeEls.get(wireId);
419
475
  if (badge) {
420
- const mid = routeMid(pts);
476
+ const mid = (isFlow && routesBag.badges && routesBag.badges.get(wireId)) || routeMid(pts);
421
477
  badge.style.left = `${mid.x}px`;
422
478
  badge.style.top = `${mid.y}px`;
423
479
  }
@@ -463,7 +519,7 @@ export function createGraphView(host, {
463
519
  seenB.add(w.id);
464
520
  let badge = badgeEls.get(w.id);
465
521
  if (!badge) { badge = h('div', 'wbadge'); badge.dataset.wireId = w.id; badgeEls.set(w.id, badge); world.appendChild(badge); }
466
- badge.textContent = `≤${budget}`;
522
+ badge.textContent = isFlow ? `${budget}×` : `≤${budget}`;
467
523
  }
468
524
  dCache.delete(w.id); // geometry may have moved: force one write
469
525
  paintWire(w.id);
@@ -490,13 +546,22 @@ export function createGraphView(host, {
490
546
  for (const [id, el] of wireEls) el.classList.toggle('bad', badWires.has(id));
491
547
  }
492
548
 
549
+ /** flow: lay the caller's template out in rows and return a POSITIONED COPY (never mutate the caller's). */
550
+ function layoutFlow(template) {
551
+ flowLay = flowLayout(template, portsFn, { width: flowWidth || FLOW_DEFAULT_WIDTH, scale: S, band: hasBand, agentOrder: order });
552
+ stage.style.height = `${flowLay.height}px`;
553
+ return { ...template, nodes: template.nodes.map((n) => (isNodeObj(n) && flowLay.positions[n.id] ? { ...n, ...flowLay.positions[n.id] } : n)) };
554
+ }
555
+
493
556
  function render(template, state = {}) {
494
- current = template;
557
+ source = template;
558
+ current = isFlow ? layoutFlow(template) : template;
559
+ // ALL FOUR ctx fields read `current`: in flow mode `template` still carries the caller's x/y.
495
560
  ctx = {
496
- byId: new Map(template.nodes.map((n) => [n.id, n])),
497
- wireById: new Map(template.wires.map((w) => [w.id, w])),
498
- wiredInputs: new Set(template.wires.filter((w) => w && w.to).map((w) => `${w.to.node}.${w.to.port}`)),
499
- loopWireIds: classifyLoops(template, portsFn).loopWireIds,
561
+ byId: new Map(current.nodes.map((n) => [n.id, n])),
562
+ wireById: new Map(current.wires.map((w) => [w.id, w])),
563
+ wiredInputs: new Set(current.wires.filter((w) => w && w.to).map((w) => `${w.to.node}.${w.to.port}`)),
564
+ loopWireIds: classifyLoops(current, portsFn).loopWireIds,
500
565
  };
501
566
  renderNodes();
502
567
  renderWires();
@@ -521,7 +586,7 @@ export function createGraphView(host, {
521
586
  * the card union, and a fit must never clip it (D9). */
522
587
  function bounds(pad = 0) {
523
588
  if (!current || !current.nodes.length) return null;
524
- const base = graphBounds(current, portsAt, { pad: 0, footerRowsOf: (n) => footers.get(n.id) || 0 });
589
+ const base = graphBounds(current, portsAt, { pad: 0, footerRowsOf: (n) => footers.get(n.id) || 0, ...geo });
525
590
  if (!base) return null;
526
591
  let x0 = base.x; let y0 = base.y; let x1 = base.x + base.w; let y1 = base.y + base.h;
527
592
  for (const pts of routesBag.routes.values()) {
@@ -556,7 +621,7 @@ export function createGraphView(host, {
556
621
  const toScreen = (wx, wy) => ({ x: wx * T.z + T.x, y: wy * T.z + T.y });
557
622
 
558
623
  const view = {
559
- stage, world, wiresEl, ghostEl: ghost, mode, stats, schedule, wheelPan,
624
+ stage, world, wiresEl, ghostEl: ghost, mode, stats, schedule,
560
625
  zoomMin: zMin, zoomMax: zMax,
561
626
  render,
562
627
  setTransform,
@@ -566,7 +631,22 @@ export function createGraphView(host, {
566
631
  template: () => current,
567
632
  ports: (node) => portsAt(node),
568
633
  size: (node) => sizeOf(node),
569
- anchor: (node, portId, dir) => portAnchor(node, portsAt(node), portId, dir),
634
+ anchor: (node, portId, dir) => portAnchor(node, portsAt(node), portId, dir, geo),
635
+ layout,
636
+ /** flow: re-lay out for a host width (px) and repaint; returns the layout (null outside flow mode). */
637
+ relayout(width) {
638
+ if (!isFlow || !source) return flowLay;
639
+ flowWidth = Math.max(0, Number(width) || 0);
640
+ render(source, {});
641
+ setTransform({ x: 0, y: 0, z: 1 });
642
+ return flowLay;
643
+ },
644
+ flowLayout: () => flowLay,
645
+ /** Replace the band data for some nodes (the tunables table drives this); geometry never moves. */
646
+ setBands(map) {
647
+ bandOverride = map ? new Map(Object.entries(map)) : null;
648
+ for (const [id, el] of nodeEls) { const node = ctx && ctx.byId.get(id); if (node) paintBand(el, node); }
649
+ },
570
650
  incidentOf: (nodeId) => incident.get(nodeId) || new Set(),
571
651
  isLoopWire: (wireId) => Boolean(ctx && ctx.loopWireIds.has(wireId)),
572
652
  setSelection(sel) {
@@ -765,6 +845,7 @@ export function createGraphView(host, {
765
845
  },
766
846
  /** Static hosts: fit the graph into a card of width `w` (ResizeObserver-driven). */
767
847
  fitToWidth(w) {
848
+ if (isFlow) return view.relayout(w);
768
849
  const r = view.readRect();
769
850
  const b = bounds(60);
770
851
  if (!b) return;
@@ -772,37 +853,110 @@ export function createGraphView(host, {
772
853
  const f = fitBounds(b, { width: vw, height: Number.MAX_SAFE_INTEGER }, { zoomMin: zMin, zoomMax: 1 }); // width decides z
773
854
  setTransform({ x: f.tx, y: (Math.max(1, r.height || 0) - b.h * f.z) / 2 - b.y * f.z, z: f.z });
774
855
  },
775
- /** Wheel/zoom nav for `monitor` hosts. `static` gets nothing; `edit` binds its
776
- * own richer pipeline in composer.mjs and does NOT call this. */
777
- createNav({ wheelPan: pan = wheelPan, onEngaged = null } = {}) {
856
+ /** Wheel zoom + (Task 2) left-drag pan for `monitor` hosts. `static` gets
857
+ * nothing; `edit` binds its own richer pipeline in composer.mjs and does NOT
858
+ * call this. POLICY (2026-09-10): a modifier-less wheel is the PAGE's — this
859
+ * canvas never traps a scroll — and ⌘/ctrl+wheel zooms about the cursor.
860
+ * The trackpad pinch arrives as ctrl+wheel on macOS, Windows and Linux alike.
861
+ * `onTransform` fires after every transform the NAV writes (the host repaints
862
+ * its zoom buttons off it); a programmatic setTransform/fit never calls it. */
863
+ createNav({ onTransform = null } = {}) {
778
864
  if (mode === 'static') return { destroy() {} };
779
- let engaged = pan === 'always';
780
- const setEngaged = (v) => { if (engaged !== v) { engaged = v; if (onEngaged) onEngaged(v); } };
865
+ const emit = () => { if (onTransform) onTransform({ ...T }); };
781
866
  const onWheel = (ev) => {
782
- const zoom = ev.ctrlKey || ev.metaKey;
783
- if (!zoom && !engaged) return; // engaged-only: let the PAGE scroll
867
+ if (!(ev.ctrlKey || ev.metaKey)) return; // the page keeps its scroll
784
868
  ev.preventDefault();
869
+ readRect(); // the page may have scrolled since the last fit
785
870
  const m = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? (R.height || 560) : 1;
786
- if (zoom) { zoomAbout(T.z * Math.exp(-ev.deltaY * m * 0.002), ev.clientX - R.left, ev.clientY - R.top); return; }
787
- setTransform({ x: T.x - ev.deltaX * m, y: T.y - ev.deltaY * m, z: T.z });
871
+ zoomAbout(T.z * Math.exp(-ev.deltaY * m * ZOOM_K), ev.clientX - R.left, ev.clientY - R.top);
872
+ emit();
788
873
  };
789
- const engage = () => setEngaged(true);
790
- const disengage = (ev) => { if (pan !== 'always' && !stage.contains(ev.target)) setEngaged(false); };
791
- const onKey = (ev) => { if (ev.key === 'Escape' && pan !== 'always') setEngaged(false); };
792
- view.readRect();
874
+ // ---- left-drag pan ---------------------------------------------------
875
+ // The press is NOT preventDefault'ed: the run host delegates .xrow /
876
+ // .xtoggle / .ngate / .xresult clicks off this very stage, so a press that
877
+ // never crosses DRAG_PX has to stay a click. Past the threshold the gesture
878
+ // is a pan, and the click the browser fires at the end of it is swallowed.
879
+ let drag = null;
880
+ let swallowT = 0;
881
+ function swallow(ev) { ev.stopPropagation(); ev.preventDefault(); disarm(); }
882
+ function disarm() {
883
+ doc.removeEventListener('click', swallow, true);
884
+ if (swallowT) { win.clearTimeout(swallowT); swallowT = 0; }
885
+ }
886
+ function armSwallow() {
887
+ disarm();
888
+ doc.addEventListener('click', swallow, true);
889
+ swallowT = win.setTimeout(disarm, 0);
890
+ }
891
+ function settle() {
892
+ if (!drag) return;
893
+ setTransform({ x: drag.ox + (drag.px - drag.sx), y: drag.oy + (drag.py - drag.sy), z: T.z });
894
+ emit();
895
+ }
896
+ function pump() {
897
+ if (!drag || drag.pending) return;
898
+ drag.pending = true;
899
+ schedule(() => { if (drag) { drag.pending = false; settle(); } });
900
+ }
901
+ /** Drop the gesture WITHOUT settling. Chrome reports pointercancel at
902
+ * client (0,0), so settling off it would teleport the graph by the whole
903
+ * press offset — and leave untouched() false, killing the auto re-fit.
904
+ * The last rAF settle already left the pan where the user saw it. */
905
+ function onCancel() { endDrag(); }
906
+ function endDrag() {
907
+ if (!drag) return;
908
+ const id = drag.id;
909
+ drag = null; // FIRST: releasePointerCapture below
910
+ stage.classList.remove('panning'); // can re-enter through lostpointercapture
911
+ doc.removeEventListener('pointermove', onMove);
912
+ doc.removeEventListener('pointerup', onEnd);
913
+ doc.removeEventListener('pointercancel', onCancel);
914
+ stage.removeEventListener('lostpointercapture', onCancel);
915
+ win.removeEventListener('blur', onCancel);
916
+ try { if (stage.hasPointerCapture?.(id)) stage.releasePointerCapture(id); } catch { /* already gone */ }
917
+ }
918
+ function onDown(ev) {
919
+ if (drag || ev.button !== 0) return;
920
+ if (ev.pointerType && ev.pointerType !== 'mouse') return;
921
+ readRect();
922
+ drag = { id: ev.pointerId, sx: ev.clientX, sy: ev.clientY, px: ev.clientX, py: ev.clientY,
923
+ ox: T.x, oy: T.y, moved: false, pending: false };
924
+ doc.addEventListener('pointermove', onMove);
925
+ doc.addEventListener('pointerup', onEnd);
926
+ doc.addEventListener('pointercancel', onCancel);
927
+ stage.addEventListener('lostpointercapture', onCancel);
928
+ win.addEventListener('blur', onCancel);
929
+ }
930
+ function onMove(ev) {
931
+ if (!drag || ev.pointerId !== drag.id) return;
932
+ if (!ev.buttons) { endDrag(); return; } // a release this document never saw
933
+ drag.px = ev.clientX; drag.py = ev.clientY;
934
+ if (!drag.moved) {
935
+ if (Math.abs(drag.px - drag.sx) < DRAG_PX && Math.abs(drag.py - drag.sy) < DRAG_PX) return;
936
+ drag.moved = true;
937
+ drag.ox = T.x; drag.oy = T.y; // a re-fit may have landed since the press
938
+ stage.classList.add('panning');
939
+ try { stage.setPointerCapture?.(drag.id); } catch { /* synthetic pointer */ }
940
+ }
941
+ ev.preventDefault();
942
+ pump();
943
+ }
944
+ function onEnd(ev) {
945
+ if (!drag || (ev.pointerId != null && ev.pointerId !== drag.id)) return;
946
+ const moved = drag.moved;
947
+ if (moved) { drag.px = ev.clientX; drag.py = ev.clientY; settle(); }
948
+ endDrag();
949
+ if (moved) armSwallow();
950
+ }
951
+ readRect();
793
952
  stage.addEventListener('wheel', onWheel, { passive: false });
794
- stage.addEventListener('pointerdown', engage);
795
- stage.addEventListener('focus', engage);
796
- doc.addEventListener('pointerdown', disengage, true);
797
- doc.addEventListener('keydown', onKey);
953
+ stage.addEventListener('pointerdown', onDown);
798
954
  const nav = {
799
- isEngaged: () => engaged,
800
955
  destroy() {
801
956
  stage.removeEventListener('wheel', onWheel);
802
- stage.removeEventListener('pointerdown', engage);
803
- stage.removeEventListener('focus', engage);
804
- doc.removeEventListener('pointerdown', disengage, true);
805
- doc.removeEventListener('keydown', onKey);
957
+ stage.removeEventListener('pointerdown', onDown);
958
+ endDrag();
959
+ disarm();
806
960
  },
807
961
  };
808
962
  navs.push(nav);
@@ -818,13 +972,14 @@ export function createGraphView(host, {
818
972
  const head = el.querySelector(':scope > .nhead');
819
973
  if (head) delete head.dataset.sig;
820
974
  }
821
- if (current) render(current, {});
975
+ if (current) render(source || current, {});
822
976
  },
823
977
  destroy() {
824
978
  for (const n of navs.splice(0)) n.destroy();
825
979
  stage.remove();
826
980
  nodeEls.clear(); wireEls.clear(); badgeEls.clear(); incident.clear(); dCache.clear(); footers.clear();
827
981
  current = null; ctx = null;
982
+ source = null; flowLay = null; bandOverride = null;
828
983
  },
829
984
  };
830
985
  // Internals the later tasks' fast paths close over.
@@ -842,17 +997,30 @@ export function thumbnailFor(template, portsFn, { width = 240, height = 96 } = {
842
997
  /** A non-interactive graph for a fixed-width card (saved rows, Running list).
843
998
  * NO listeners: the card's own click handler must keep working, which is why
844
999
  * `.gv-static .node` is pointer-events:none in style.css. */
845
- export function mountStaticGraph(host, template, { doc = globalThis.document, portsFn, agents = {}, width = 0, viewport = null } = {}) {
846
- const view = createGraphView(host, { doc, mode: 'static', portsFn, agents, viewport });
1000
+ export function mountStaticGraph(host, template, {
1001
+ doc = globalThis.document, portsFn, agents = {}, width = 0, viewport = null,
1002
+ scale = 1, layout = 'auto', band = null, order = null, onLayout = null,
1003
+ } = {}) {
1004
+ const view = createGraphView(host, { doc, mode: 'static', portsFn, agents, viewport, scale, layout, band, order });
847
1005
  view.render(template, {});
848
- const paint = () => view.fitToWidth(width || host.clientWidth || 0);
1006
+ const isFlow = layout === 'flow';
1007
+ const widthOf = () => host.clientWidth || width || 0;
1008
+ const paint = () => {
1009
+ if (!isFlow) { view.fitToWidth(width || host.clientWidth || 0); return; }
1010
+ const lay = view.relayout(widthOf()); // 0 → FLOW_DEFAULT_WIDTH inside the view
1011
+ host.style.height = `${lay.height}px`; // the host grows with the rows (min 120)
1012
+ if (onLayout) onLayout(lay);
1013
+ };
849
1014
  paint();
850
1015
  const win = doc.defaultView || globalThis;
1016
+ const inner = view.destroy;
1017
+ let ro = null;
851
1018
  if (typeof win.ResizeObserver === 'function') {
852
- const ro = new win.ResizeObserver(() => view.fitToWidth(host.clientWidth || width || 0));
1019
+ let lastW = host.clientWidth;
1020
+ ro = new win.ResizeObserver(() => { const w = host.clientWidth; if (isFlow && w === lastW) return; lastW = w; paint(); });
853
1021
  ro.observe(host);
854
- const inner = view.destroy;
855
- view.destroy = () => { ro.disconnect(); inner(); };
856
1022
  }
1023
+ let dead = false;
1024
+ view.destroy = () => { if (dead) return; dead = true; if (ro) ro.disconnect(); inner(); if (isFlow) host.style.removeProperty('height'); };
857
1025
  return view;
858
1026
  }
@@ -0,0 +1,215 @@
1
+ // ui/public/guide-spot.mjs
2
+ // The guided click (docs/getting-started.md): scrim-dim the page, keep ONE real
3
+ // control lit and interactive above it, ring it, say why in one balloon.
4
+ // Action-driven, never narrated — the target's real click is what advances or
5
+ // ends a guide (the caller's onTargetClick); the scrim, Esc and Skip dismiss
6
+ // (onDismiss). Soft-block: nothing here can trap the user.
7
+ //
8
+ // Layering (style.css .guide-*): `spotlight` sits above the Ask dock (40) and
9
+ // below every .viewer-modal (50) — a dialog that opens on the target's click
10
+ // naturally covers it. `pointer` drops the scrim + elevation for a target inside
11
+ // an open dialog (a modal is its own stacking context, so elevation can't cross
12
+ // it, and its backdrop already dims) and rides above the modal layer instead.
13
+ //
14
+ // Pure DOM: `doc` / `win` are injectable so test/ui-guide-spot.test.mjs runs it
15
+ // in jsdom. No app state, no fetch.
16
+
17
+ const ATTACH_TRIES = 120; // ~2 s at 60 fps: a view's list may still be fetching
18
+ const REACQUIRE_FRAMES = 60; // ~1 s: how long a vanished control may take to be repainted
19
+
20
+ const frame = (win) => (typeof win.requestAnimationFrame === 'function'
21
+ ? (fn) => win.requestAnimationFrame(fn)
22
+ : (fn) => win.setTimeout(fn, 16));
23
+ const cancel = (win) => (typeof win.cancelAnimationFrame === 'function'
24
+ ? (h) => win.cancelAnimationFrame(h)
25
+ : (h) => win.clearTimeout(h));
26
+
27
+ /** A target that exists but is not showing (hidden attr, display:none, zero box)
28
+ * is treated as absent: the attach loop keeps waiting for it. */
29
+ function visible(el) {
30
+ if (!el || el.hidden) return false;
31
+ const b = el.getBoundingClientRect();
32
+ return b.width > 0 || b.height > 0;
33
+ }
34
+
35
+ /**
36
+ * @param {object} o
37
+ * @param {string|string[]} o.target selector of the ONE control to light up — or
38
+ * fallbacks in preference order, the first one
39
+ * showing wins (a control that only exists in
40
+ * some states, then the cell it would live in)
41
+ * @param {string|string[]} o.text one line of why (no markup); an array pairs
42
+ * with the target array by index
43
+ * @param {'spotlight'|'pointer'} [o.mode]
44
+ * @param {string[]} [o.lift] ancestors that own a stacking context the target
45
+ * cannot escape (e.g. `.ask-dock`): they are lifted
46
+ * above the scrim with it
47
+ * @param {number} [o.tries] attach retries before giving up (→ onDismiss)
48
+ * @param {() => void} o.onDismiss
49
+ * @param {() => void} o.onTargetClick
50
+ * @param {Document} [o.doc]
51
+ * @param {Window} [o.win]
52
+ * @returns {{destroy:() => void, layer:Element}}
53
+ */
54
+ export function createGuideSpot({
55
+ target, text, mode = 'spotlight', lift = [], tries = ATTACH_TRIES,
56
+ onDismiss, onTargetClick, doc = globalThis.document, win = globalThis.window,
57
+ }) {
58
+ const targets = Array.isArray(target) ? target : [target];
59
+ const texts = Array.isArray(text) ? text : [text];
60
+ const raf = frame(win);
61
+ const caf = cancel(win);
62
+ let alive = true;
63
+ let handle = 0;
64
+ let cleanup = () => {};
65
+
66
+ const layer = doc.createElement('div');
67
+ layer.className = `guide-layer ${mode}`;
68
+ layer.dataset.target = targets.join(', ');
69
+ let scrim = null;
70
+ if (mode === 'spotlight') {
71
+ scrim = doc.createElement('div');
72
+ scrim.className = 'guide-scrim';
73
+ scrim.addEventListener('click', () => dismiss());
74
+ layer.appendChild(scrim);
75
+ }
76
+ const ring = doc.createElement('div');
77
+ ring.className = 'guide-ring';
78
+ ring.hidden = true;
79
+ const balloon = doc.createElement('div');
80
+ balloon.className = 'guide-balloon';
81
+ balloon.setAttribute('role', 'status');
82
+ balloon.hidden = true;
83
+ const line = doc.createElement('span');
84
+ line.className = 'guide-text';
85
+ line.textContent = texts[0] || '';
86
+ const skip = doc.createElement('button');
87
+ skip.type = 'button';
88
+ skip.className = 'guide-skip';
89
+ skip.textContent = 'Skip';
90
+ skip.addEventListener('click', () => dismiss());
91
+ balloon.append(line, skip);
92
+ layer.append(ring, balloon);
93
+ doc.body.appendChild(layer);
94
+
95
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); dismiss(); } };
96
+ doc.addEventListener('keydown', onKey);
97
+
98
+ function destroy() {
99
+ if (!alive) return;
100
+ alive = false;
101
+ caf(handle);
102
+ cleanup();
103
+ doc.removeEventListener('keydown', onKey);
104
+ layer.remove();
105
+ }
106
+ function dismiss() {
107
+ if (!alive) return;
108
+ destroy();
109
+ if (onDismiss) onDismiss();
110
+ }
111
+
112
+ /** Centre the balloon under its target, clamped to the viewport, arrow aimed
113
+ * at the target's centre WITHIN the balloon; above the target when there is
114
+ * no room below. Measured after layout: the balloon's width depends on its text. */
115
+ function place(rect) {
116
+ const vw = win.innerWidth || doc.documentElement.clientWidth || 0;
117
+ const vh = win.innerHeight || doc.documentElement.clientHeight || 0;
118
+ const pad = 6;
119
+ ring.style.left = `${rect.left - pad}px`;
120
+ ring.style.top = `${rect.top - pad}px`;
121
+ ring.style.width = `${rect.width + pad * 2}px`;
122
+ ring.style.height = `${rect.height + pad * 2}px`;
123
+ ring.hidden = false;
124
+ balloon.hidden = false;
125
+ const box = balloon.getBoundingClientRect();
126
+ const centre = rect.left + rect.width / 2;
127
+ const left = Math.max(8, Math.min(centre - box.width / 2, Math.max(8, vw - box.width - 8)));
128
+ const gap = 14;
129
+ const above = vh > 0 && rect.bottom + gap + box.height > vh - 8 && rect.top - gap - box.height >= 8;
130
+ balloon.classList.toggle('above', above);
131
+ balloon.style.left = `${left}px`;
132
+ balloon.style.top = above ? `${rect.top - box.height - gap}px` : `${rect.bottom + gap}px`;
133
+ balloon.style.setProperty('--arrow-x', `${Math.max(16, Math.min(centre - left, box.width - 16))}px`);
134
+ }
135
+
136
+ let attempts = 0;
137
+ function attach() {
138
+ if (!alive) return;
139
+ let el = null; let which = 0;
140
+ for (let i = 0; i < targets.length && !el; i++) {
141
+ const cand = doc.querySelector(targets[i]);
142
+ if (visible(cand)) { el = cand; which = i; }
143
+ }
144
+ if (!el) {
145
+ // The target may mount a tick after us (a list still fetching, a dialog
146
+ // opening) — retry, then give up quietly rather than spotlighting nothing.
147
+ if (++attempts < tries) { handle = raf(attach); return; }
148
+ dismiss();
149
+ return;
150
+ }
151
+ el.classList.add('guide-target');
152
+ line.textContent = texts[which] ?? texts[0] ?? '';
153
+ layer.dataset.target = targets[which];
154
+ // The ring follows the control's own corner radius (a pill stays a pill).
155
+ try {
156
+ const r = win.getComputedStyle(el).borderTopLeftRadius;
157
+ if (r && r !== '0px') ring.style.borderRadius = `calc(${r} + 6px)`;
158
+ } catch { /* jsdom: keep the stylesheet's radius */ }
159
+ const lifted = [];
160
+ for (const sel of lift) {
161
+ const a = el.closest(sel);
162
+ if (!a) continue;
163
+ a.classList.add('guide-lift');
164
+ // z-index needs a positioned box; a static ancestor gets position:relative
165
+ // (never a fixed/absolute one — that would move it).
166
+ try { if (win.getComputedStyle(a).position === 'static') a.classList.add('guide-lift-static'); } catch { /* jsdom */ }
167
+ lifted.push(a);
168
+ }
169
+ // Glide to the control (the ring follows it per frame, so the eye is led
170
+ // down the page); reduced motion jumps.
171
+ if (typeof el.scrollIntoView === 'function') {
172
+ let reduced = false;
173
+ try { reduced = !!(win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch { /* jsdom */ }
174
+ try { el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduced ? 'auto' : 'smooth' }); } catch { /* jsdom */ }
175
+ }
176
+ const onClick = () => {
177
+ if (!alive) return;
178
+ // Let the control's own handlers run first (they are registered before
179
+ // ours), then hand over: the caller re-reads real UI state on a tick.
180
+ if (onTargetClick) onTargetClick();
181
+ };
182
+ el.addEventListener('click', onClick);
183
+ // Follow the target every frame rather than on resize/scroll: the main
184
+ // column scrolls, details open, a sheet slides under a transform — none
185
+ // of which fires a resize. One rect read per frame, only while a guide is
186
+ // up, and a re-place only when the geometry actually changed.
187
+ let last = '';
188
+ const track = () => {
189
+ if (!alive) return;
190
+ if (!doc.contains(el) || !visible(el)) {
191
+ // The control went away — usually a list repainting its rows (the same
192
+ // selector matches the replacement a frame later). Detach and re-acquire
193
+ // for a short grace period; only a control that stays gone ends the guide.
194
+ cleanup();
195
+ cleanup = () => {};
196
+ attempts = Math.max(0, tries - REACQUIRE_FRAMES);
197
+ handle = raf(attach);
198
+ return;
199
+ }
200
+ const b = el.getBoundingClientRect();
201
+ const key = `${b.top}|${b.left}|${b.width}|${b.height}`;
202
+ if (key !== last) { last = key; place(b); }
203
+ handle = raf(track);
204
+ };
205
+ cleanup = () => {
206
+ el.classList.remove('guide-target');
207
+ for (const a of lifted) a.classList.remove('guide-lift', 'guide-lift-static');
208
+ el.removeEventListener('click', onClick);
209
+ };
210
+ track();
211
+ }
212
+ attach();
213
+
214
+ return { destroy, layer };
215
+ }