@worca/app 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/ui/public/graph/view.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
el.style.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
497
|
-
wireById: new Map(
|
|
498
|
-
wiredInputs: new Set(
|
|
499
|
-
loopWireIds: classifyLoops(
|
|
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,
|
|
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
|
|
776
|
-
* own richer pipeline in composer.mjs and does NOT
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
871
|
+
zoomAbout(T.z * Math.exp(-ev.deltaY * m * ZOOM_K), ev.clientX - R.left, ev.clientY - R.top);
|
|
872
|
+
emit();
|
|
788
873
|
};
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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',
|
|
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',
|
|
803
|
-
|
|
804
|
-
|
|
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, {
|
|
846
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|