@caddis/cli 0.4.44 → 0.4.45

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 (92) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  3. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  4. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/README.md +53 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +103 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +11 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +123 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +79 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +55 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +37 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +388 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +64 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +38 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +171 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +231 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +118 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +142 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +108 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +240 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +69 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +57 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +107 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +165 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +92 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +155 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +13 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +11 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +88 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +35 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +66 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +44 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +157 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +20 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +22 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +1744 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +61 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  48. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +53 -0
  49. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +103 -0
  50. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +11 -0
  51. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  52. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  53. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +123 -0
  54. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  55. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +79 -0
  56. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +55 -0
  57. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +37 -0
  58. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +388 -0
  59. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  60. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  61. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +64 -0
  62. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  63. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  64. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +38 -0
  65. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +171 -0
  66. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +231 -0
  67. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +118 -0
  68. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +142 -0
  69. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +108 -0
  70. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +240 -0
  71. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +69 -0
  72. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +57 -0
  73. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +107 -0
  74. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +165 -0
  75. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +92 -0
  76. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +155 -0
  77. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +13 -0
  78. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +11 -0
  79. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +88 -0
  80. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +35 -0
  81. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +66 -0
  82. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +44 -0
  83. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +157 -0
  84. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +20 -0
  85. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +22 -0
  86. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  87. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  88. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +1744 -0
  89. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +61 -0
  90. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  91. package/bundles/manifest.json +2 -2
  92. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ // View rules: which nodes and links show in which view, and in which state. Pure, no DOM.
2
+ // A model may define views (for example "Today" and "Complete"). The first view is the baseline.
3
+ // A model with no views has one implicit view named "all".
4
+
5
+ import type { Model, NodeDef, LinkDef, Bucket } from "./model.ts";
6
+
7
+ export const baselineView = (m: Model): string => m.views?.[0]?.id ?? "all";
8
+ export const defaultView = (m: Model): string => (m.views?.find(v => v.default) ?? m.views?.[0])?.id ?? "all";
9
+
10
+ export const nodeVisible = (_m: Model, view: string, n: NodeDef): boolean => !n.visibleIn || n.visibleIn.includes(view);
11
+ export const nodeStateId = (_m: Model, view: string, n: NodeDef): string => n.stateIn?.[view] ?? n.state;
12
+
13
+ export function linkVisible(m: Model, view: string, l: LinkDef, nodes: Map<string, NodeDef>): boolean {
14
+ if (l.visibleIn && !l.visibleIn.includes(view)) return false;
15
+ const a = nodes.get(l.from);
16
+ const b = nodes.get(l.to);
17
+ return !!a && !!b && nodeVisible(m, view, a) && nodeVisible(m, view, b);
18
+ }
19
+ export const linkStateId = (_m: Model, view: string, l: LinkDef): string => l.stateIn?.[view] ?? l.state;
20
+
21
+ /** An unknown state counts as a gap: a typo must never read as "good". */
22
+ export const bucketOf = (m: Model, stateId: string): Bucket => m.states[stateId]?.bucket ?? "gap";
23
+
24
+ /** Visible now, but not in the baseline view: the view adds this node. */
25
+ export const isNew = (m: Model, view: string, n: NodeDef): boolean =>
26
+ view !== baselineView(m) && nodeVisible(m, view, n) && !nodeVisible(m, baselineView(m), n);
27
+
28
+ /** A gap in the baseline view that this view has filled. */
29
+ export const isImproved = (m: Model, view: string, n: NodeDef): boolean => {
30
+ const base = baselineView(m);
31
+ return view !== base && nodeVisible(m, base, n) && bucketOf(m, nodeStateId(m, base, n)) === "gap" && bucketOf(m, nodeStateId(m, view, n)) !== "gap";
32
+ };
33
+
34
+ /** A portal chip is an alias of the node it points at. */
35
+ export const canonOf = (nodes: Map<string, NodeDef>, id: string): string => nodes.get(id)?.ref ?? id;
@@ -0,0 +1,66 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { Model } from "./model.ts";
4
+ import { stateCss, toneVar } from "./stateCss.ts";
5
+
6
+ const states: Model["states"] = {
7
+ live: { word: "LIVE", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" },
8
+ doc: { word: "DOC", tone: "muted", border: "dashed", edge: "dash", motion: "flow-slow", bucket: "neutral" },
9
+ code: { word: "CODE", tone: "muted", border: "dashed", edge: "dash-short", motion: "flow-slow", bucket: "neutral" },
10
+ gap: { word: "GAP", tone: "warn", border: "dotted", edge: "dot", motion: "breathe", bucket: "gap" },
11
+ };
12
+
13
+ test("toneVar maps every tone to a CSS variable", () => {
14
+ for (const t of ["ok", "warn", "crit", "info", "muted", "accent"] as const) assert.match(toneVar(t), /^var\(--[a-z]+\)$/);
15
+ });
16
+ test("each state gets a node rule, a word rule and an edge rule", () => {
17
+ const css = stateCss(states);
18
+ for (const id of Object.keys(states)) {
19
+ assert.ok(css.includes(`.node.st-${id}`), `node ${id}`);
20
+ assert.ok(css.includes(`.ev.st-${id}`), `word ${id}`);
21
+ assert.ok(css.includes(`path.edge.st-${id}`), `edge ${id}`);
22
+ }
23
+ });
24
+ test("a dotted state is a tinted card with a dotted border", () => {
25
+ const css = stateCss(states);
26
+ assert.match(css, /\.node\.st-gap\s*\{[^}]*dotted/);
27
+ assert.match(css, /\.node\.st-gap\s*\{[^}]*background/);
28
+ });
29
+ test("a solid state has a solid left border", () => assert.match(stateCss(states), /\.node\.st-live\s*\{[^}]*border-left:4px solid/));
30
+ test("breathe adds the breathing animation only to the node", () => {
31
+ const css = stateCss(states);
32
+ assert.match(css, /\.node\.st-gap:not\(\.dim\)\s*\{[^}]*animation:breathe/);
33
+ assert.doesNotMatch(css, /path\.edge\.st-gap\s*\{[^}]*animation/);
34
+ });
35
+ test("flow speed follows the motion: fast for flow, slow for flow-slow", () => {
36
+ const css = stateCss(states);
37
+ assert.match(css, /path\.edge\.st-live\s*\{[^}]*flow 3s/);
38
+ assert.match(css, /path\.edge\.st-doc\s*\{[^}]*flowd 7s/);
39
+ assert.match(css, /path\.edge\.st-code\s*\{[^}]*flowc 7s/);
40
+ });
41
+ test("the dash pattern follows the edge style", () => {
42
+ const css = stateCss(states);
43
+ assert.match(css, /path\.edge\.st-live\s*\{[^}]*stroke-dasharray:5 9/);
44
+ assert.match(css, /path\.edge\.st-doc\s*\{[^}]*stroke-dasharray:7 6/);
45
+ assert.match(css, /path\.edge\.st-code\s*\{[^}]*stroke-dasharray:3 6/);
46
+ assert.match(css, /path\.edge\.st-gap\s*\{[^}]*stroke-dasharray:2 6/);
47
+ });
48
+ test("a flowing state turns solid under reduced motion", () => {
49
+ const css = stateCss(states);
50
+ assert.match(css, /@media \(prefers-reduced-motion: reduce\)[^@]*path\.edge\.st-live[^}]*stroke-dasharray:none/);
51
+ });
52
+ test("an unexpected edge or motion value never prints 'undefined' into the CSS", () => {
53
+ const css = stateCss({ odd: { word: "ODD", tone: "ok", border: "solid", edge: "wavy" as any, motion: "spin" as any, bucket: "good" } });
54
+ assert.doesNotMatch(css, /undefined/);
55
+ });
56
+ test("names from Object.prototype are not accepted as an edge style", () => {
57
+ for (const edge of ["constructor", "toString", "__proto__", "hasOwnProperty"]) {
58
+ const css = stateCss({ odd: { word: "ODD", tone: edge as any, border: "solid", edge: edge as any, motion: "flow", bucket: "good" } });
59
+ assert.doesNotMatch(css, /native code|function|undefined|\[object/, edge);
60
+ }
61
+ });
62
+ test("a state id with markup characters cannot break out of the selector", () => {
63
+ const css = stateCss({ 'x"}<script>': states.live });
64
+ assert.doesNotMatch(css, /<script>/);
65
+ assert.doesNotMatch(css, /x"\}/);
66
+ });
@@ -0,0 +1,44 @@
1
+ // CSS generated from the model's states. Each state becomes a class (st-<id>) for its card, its word
2
+ // and its links, so the engine never assumes state names. The result goes BEFORE the static styles, so
3
+ // the static rules for hover, select and failure win when specificity is equal.
4
+
5
+ import type { Model, Tone, EdgeStyle } from "./model.ts";
6
+
7
+ /** A state id used as a CSS class. The model validator also restricts ids to this alphabet. */
8
+ export const cssId = (id: string): string => id.replace(/[^A-Za-z0-9_-]/g, "_");
9
+
10
+ const TONES: Record<Tone, string> = { ok: "var(--ok)", warn: "var(--warn)", crit: "var(--crit)", info: "var(--up)", muted: "var(--muted)", accent: "var(--accent)" };
11
+ export const toneVar = (t: Tone): string => (Object.hasOwn(TONES, t) ? TONES[t] : TONES.muted);
12
+
13
+ export const DASH: Record<EdgeStyle, string> = { flow: "5 9", dash: "7 6", "dash-short": "3 6", dot: "2 6" };
14
+ const KEYFRAMES: Record<EdgeStyle, string> = { flow: "flow", dash: "flowd", "dash-short": "flowc", dot: "" };
15
+
16
+ export function stateCss(states: Model["states"]): string {
17
+ const out: string[] = [];
18
+ const reduced: string[] = [];
19
+ for (const [id, s] of Object.entries(states)) {
20
+ const c = cssId(id);
21
+ const T = toneVar(s.tone);
22
+ if (s.border === "dotted") {
23
+ out.push(`.node.st-${c} { border:1px dotted ${T}; background:color-mix(in srgb, ${T} 10%, var(--panel)); --breath:${T}; }`);
24
+ out.push(`.node.st-${c} .t { color:${T}; }`);
25
+ } else {
26
+ const glow = s.bucket === "good" ? ` box-shadow:0 0 14px -4px color-mix(in srgb, ${T} 55%, transparent), var(--shadow);` : "";
27
+ out.push(`.node.st-${c} { border-left:4px ${s.border} ${T};${glow} --breath:${T}; }`);
28
+ }
29
+ out.push(`.ev.st-${c} { color:${T}; }`);
30
+ if (s.motion === "breathe") out.push(`.node.st-${c}:not(.dim) { animation:breathe 3.2s ease-in-out infinite; }`);
31
+
32
+ const edge: EdgeStyle = Object.hasOwn(DASH, s.edge) ? s.edge : "dash"; // a closed table: an unexpected value never reaches the CSS text
33
+ const flows = edge !== "dot" && (s.motion === "flow" || s.motion === "flow-slow");
34
+ let rule = `stroke:${T}; stroke-dasharray:${DASH[edge]}; opacity:${s.motion === "flow" ? ".85" : ".9"};`;
35
+ if (edge === "dot") rule += " stroke-linecap:round;";
36
+ if (flows) rule += ` animation:${KEYFRAMES[edge]} ${s.motion === "flow" ? "3s" : "7s"} linear infinite;`;
37
+ if (flows && s.motion === "flow") rule += ` filter:drop-shadow(0 0 3px ${T});`;
38
+ out.push(`path.edge.st-${c} { ${rule} }`);
39
+ if (s.bucket === "gap") out.push(`path.edge.hot.st-${c} { stroke:${T}; filter:drop-shadow(0 0 5px ${T}); }`);
40
+ if (flows) reduced.push(`path.edge.st-${c} { stroke-dasharray:none; }`);
41
+ }
42
+ if (reduced.length) out.push(`@media (prefers-reduced-motion: reduce) { ${reduced.join(" ")} }`);
43
+ return out.join("\n");
44
+ }
@@ -0,0 +1,157 @@
1
+ :root {
2
+ --bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
3
+ --accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
4
+ --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035);
5
+ --warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
6
+ --mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
7
+ }
8
+ @media (prefers-color-scheme: dark) {
9
+ :root:not([data-theme="light"]) {
10
+ --bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
11
+ --accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
12
+ --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
13
+ --warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
14
+ }
15
+ }
16
+ :root[data-theme="dark"] {
17
+ --bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
18
+ --accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
19
+ --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
20
+ --warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
21
+ }
22
+ * { box-sizing:border-box; }
23
+ body { margin:0; color:var(--fg); font:14px/1.45 Inter, "Segoe UI", system-ui, -apple-system, sans-serif;
24
+ background:radial-gradient(900px 380px at 8% -10%, var(--glow), transparent 60%), var(--bg); }
25
+ .mono { font-family:var(--mono); }
26
+ header.top { display:flex; align-items:center; gap:16px; padding:10px 20px; border-bottom:1px solid var(--edge); background:color-mix(in srgb, var(--panel) 88%, transparent); backdrop-filter:blur(10px); position:sticky; top:0; z-index:70; }
27
+ .brand { font-weight:700; display:flex; align-items:center; gap:8px; }
28
+ .brand i { width:10px; height:10px; border-radius:50%; background:var(--ok); animation:beat 2.6s infinite; }
29
+ .crumb { color:var(--muted); } .spacer { flex:1; }
30
+ button, select, input { font:inherit; color:var(--fg); background:var(--panel); border:1px solid var(--edge); border-radius:6px; padding:4px 11px; }
31
+ button { cursor:pointer; transition:border-color .15s, box-shadow .15s, background .15s; }
32
+ button:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--glow); }
33
+ button:focus-visible, input:focus-visible, .node:focus-visible, summary:focus-visible, #viewport:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
34
+ button[aria-pressed="true"].tb, #notesBtn[aria-pressed="true"], button.seg[aria-selected="true"] { background:var(--accent-solid); border-color:var(--accent-solid); color:#fff; }
35
+ main { width:100%; padding:12px 14px 20px; }
36
+ .titlerow { display:flex; align-items:baseline; gap:16px; flex-wrap:wrap; }
37
+ h1 { margin:0 0 2px; font-size:20px; padding-bottom:5px; border-bottom:3px solid var(--accent); display:inline-block; }
38
+ .readline { color:var(--muted); font-size:12px; }
39
+ p.help { margin:0 0 8px; color:var(--muted); max-width:130ch; }
40
+ .summary { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(0,1fr); gap:10px; margin:0 0 8px; }
41
+ .layerstrip { display:grid; grid-template-columns:repeat(9,minmax(0,1fr)); gap:1px; background:var(--rule); border:1px solid var(--rule); }
42
+ .lcell { background:var(--panel); padding:7px 9px; min-width:0; }
43
+ .lcell .ln { font-size:10.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
44
+ .lcell .lb { display:flex; height:5px; margin:5px 0 4px; background:var(--panel2); border:1px solid var(--rule); overflow:hidden; }
45
+ .lcell .lb i { display:block; height:100%; transition:width .9s cubic-bezier(.2,.8,.2,1); } .lb .good { background:var(--ok); } .lb .neutral { background:var(--muted); } .lb .gap { background:var(--warn); }
46
+ .lcell .lv { font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; }
47
+ .tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--rule); border:1px solid var(--rule); }
48
+ .tile { background:var(--panel); padding:5px 11px; text-align:left; border:0; border-radius:0; min-width:0; color:var(--fg); }
49
+ .tile .tn { font-size:20px; font-weight:600; line-height:1.1; font-variant-numeric:tabular-nums; display:block; }
50
+ .tile .tl { font-size:10.5px; color:var(--muted); display:block; margin-top:2px; }
51
+ .tile.ok .tn { color:var(--ok); } .tile.warn .tn { color:var(--warn); }
52
+ button.tile:hover { background:var(--panel2); } button.tile[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 0 -2px 0 var(--accent); }
53
+ .toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:0 0 6px; }
54
+ .tabs { display:flex; gap:0; margin-right:6px; } .tabs button { border-radius:0; } .tabs button:first-child { border-radius:6px 0 0 6px; } .tabs button:last-child { border-radius:0 6px 6px 0; }
55
+ .tabs button[aria-selected="true"] { border-color:var(--accent); font-weight:600; } .tabs button:disabled { opacity:.5; cursor:default; }
56
+ .seggrp { display:inline-flex; border:1px solid var(--edge); border-radius:6px; overflow:hidden; } .seggrp button.seg { border:0; border-radius:0; }
57
+ .toolbar input[type="search"] { min-width:280px; }
58
+ .workspace { display:block; height:max(540px, calc(100vh - 232px)); border:1px solid var(--edge); background:var(--panel); border-radius:12px; overflow:hidden; box-shadow:var(--shadow); }
59
+ .canvaswrap { position:relative; display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
60
+ #viewport { position:relative; flex:1; min-height:0; overflow:hidden; cursor:grab; touch-action:none; user-select:none;
61
+ background:radial-gradient(760px 380px at 4% 0%, var(--glow), transparent 65%), radial-gradient(640px 320px at 98% 100%, color-mix(in srgb, var(--ok) 16%, transparent), transparent 70%), linear-gradient(var(--grid) 1px, transparent 1px) 0 0/28px 28px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0/28px 28px, var(--bg); }
62
+ #viewport.panning { cursor:grabbing; } #viewport.hand .node { cursor:grab; }
63
+ #world { position:absolute; left:0; top:0; transform-origin:0 0; will-change:transform; }
64
+ #viewport.anim #world, #viewport.anim .hd, #viewport.anim .ph { transition:transform .28s cubic-bezier(.2,.8,.2,1), left .28s cubic-bezier(.2,.8,.2,1), width .28s cubic-bezier(.2,.8,.2,1); }
65
+ #edges { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
66
+ .band { position:absolute; border:1px solid var(--edge); border-radius:10px; background:color-mix(in srgb, var(--panel) 55%, transparent); transition:top .35s, height .35s, opacity .35s; pointer-events:none; }
67
+ .band.gap { border:1px dashed var(--warn); background:repeating-linear-gradient(135deg, transparent 0 9px, color-mix(in srgb, var(--warn) 7%, transparent) 9px 10px); }
68
+ .band.off { opacity:0; }
69
+ .bandlabel { position:absolute; left:12px; top:7px; display:flex; gap:8px; align-items:baseline; transform-origin:0 0; transform:scale(var(--inv,1)); white-space:nowrap; pointer-events:auto; cursor:pointer; z-index:3; }
70
+ .bandlabel b { font-size:13px; } .bandlabel .bd { font-size:10.5px; font-weight:700; padding:0 6px; border:1px solid currentColor; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
71
+ .bandlabel .bd.t-ok { color:var(--ok); } .bandlabel .bd.t-warn { color:var(--warn); } .bandlabel .bn { font-size:11.5px; color:var(--muted); }
72
+ .node { position:absolute; background:var(--panel); border:1px solid var(--edge); border-radius:8px; padding:5px 10px 7px; box-shadow:var(--shadow); transition:opacity .35s, transform .35s, box-shadow .2s, border-color .2s; cursor:pointer; z-index:2; }
73
+ .node.wide { overflow:hidden; }
74
+ .node:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--glow), var(--shadow); }
75
+ .node .t { display:flex; gap:4px; align-items:flex-start; font-weight:600; line-height:1.25; font-size:13px; }
76
+ .node .t .name { overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
77
+ .node .s { color:var(--muted); font-size:12px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
78
+ .ev { font-size:10.5px; font-weight:700; letter-spacing:.03em; }
79
+ .node.wide .t { font-weight:500; } .node.wide .s { white-space:normal; }
80
+ .node.ref { padding:4px 9px; border-style:dashed; box-shadow:none; background:color-mix(in srgb, var(--panel) 70%, transparent); } .node.ref .t { font-size:12px; } .node.ref .s { font-size:11px; }
81
+ .node.sel { outline:2px solid var(--accent); outline-offset:2px; } .node.dim { opacity:.28; } .node.off { opacity:0; transform:translateY(8px); pointer-events:none; }
82
+ .node .bar { position:absolute; left:0; right:0; bottom:0; display:flex; height:4px; overflow:hidden; border-radius:0 0 7px 7px; background:var(--panel2); } .node .bar i { display:block; height:100%; } .bar .good { background:var(--ok); } .bar .neutral { background:var(--muted); } .bar .gap { background:var(--warn); }
83
+ .newdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--accent); margin:5px 4px 0 0; flex:none; } body.alt .node.isnew .newdot { display:inline-block; }
84
+ body.alt .node.isnew.pulse { animation:newring 1.8s ease-out 1; }
85
+ .cor { position:absolute; top:5px; width:17px; height:17px; border-radius:50%; font-size:10px; display:flex; align-items:center; justify-content:center; font-weight:700; }
86
+ .cor.up { right:6px; background:var(--info-bg); color:var(--accent); } .cor.meas { right:6px; background:var(--ok-bg); color:var(--ok); } .cor.up + .cor.meas { right:27px; }
87
+ .node.hascor .t { padding-right:24px; } .node.hascor2 .t { padding-right:44px; }
88
+ #viewport.lod1 .node .s, #viewport.lod1 .bandlabel .bn { display:none; } #viewport.lod2 .node .cor, #viewport.lod2 .node.ref .s { display:none; }
89
+ path.edge { fill:none; stroke-width:1.8; transition:opacity .35s; vector-effect:non-scaling-stroke; }
90
+ path.edge.hot { stroke:var(--accent); stroke-width:2.4; opacity:1; stroke-dasharray:6 8; animation:flowhot .9s linear infinite; filter:drop-shadow(0 0 5px var(--accent)); }
91
+ path.edge.dim { opacity:.07; filter:none; } path.edge.off { opacity:0; }
92
+ .elabel { position:absolute; font-size:11px; padding:0 6px; border-radius:9px; background:var(--panel); border:1px solid var(--edge); color:var(--muted); white-space:nowrap; transform:translate(-50%,-50%); opacity:0; transition:opacity .35s; z-index:4; } .elabel.show { opacity:1; } .elabel.off { display:none; }
93
+ #cols { position:absolute; left:0; right:0; top:0; height:44px; pointer-events:none; z-index:20; background:linear-gradient(var(--bg) 62%, transparent); }
94
+ #cols .ph { position:absolute; top:3px; height:16px; font-size:10px; letter-spacing:.14em; font-weight:700; color:var(--accent); text-transform:uppercase; display:flex; align-items:center; gap:6px; overflow:hidden; }
95
+ #cols .ph::before { content:""; height:1px; background:var(--accent); width:8px; opacity:.7; } #cols .ph::after { content:"›"; margin-left:auto; padding-right:3px; opacity:.8; flex:none; }
96
+ #cols .ph span { flex:none; } #cols .ph i { flex:1; height:1px; background:var(--accent); opacity:.4; }
97
+ #cols .hd { position:absolute; top:22px; font-size:10.5px; letter-spacing:.08em; font-weight:700; color:var(--muted); text-transform:uppercase; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #cols .hd b { color:var(--fg); }
98
+ #tools { position:absolute; left:10px; top:58px; z-index:30; display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--edge); box-shadow:var(--shadow); }
99
+ #tools button { width:34px; height:32px; padding:0; border:0; border-radius:0; border-bottom:1px solid var(--rule); display:flex; align-items:center; justify-content:center; background:transparent; }
100
+ #tools button:last-child { border-bottom:0; } #tools button:hover { background:var(--panel2); color:var(--accent); box-shadow:none; } #tools button[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 3px 0 0 var(--accent); color:var(--accent); }
101
+ #tools svg { width:17px; height:17px; stroke:currentColor; fill:none; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } #tools .sep { height:1px; background:var(--edge); margin:0; }
102
+ #zoomLabel { position:absolute; left:10px; top:auto; z-index:30; font:11px var(--mono); color:var(--muted); background:var(--panel); border:1px solid var(--edge); padding:1px 6px; }
103
+ .keyline { display:flex; flex-wrap:wrap; align-items:center; gap:6px 15px; padding:6px 13px; border-top:1px solid var(--rule); background:var(--panel2); font-size:10.5px; color:var(--muted); } .keyline svg { vertical-align:middle; margin-right:6px; }
104
+ .insp { display:flex; flex-direction:column; background:var(--panel); min-height:0; min-width:0; font-family:var(--mono); position:absolute; top:0; right:0; bottom:0; width:340px; z-index:40; border-left:1px solid var(--edge); box-shadow:-14px 0 34px rgba(0,0,0,.16), -1px 0 0 var(--glow); transform:translateX(104%); visibility:hidden; transition:transform .3s cubic-bezier(.2,.8,.2,1), visibility 0s .3s; }
105
+ .insp.open { transform:none; visibility:visible; transition:transform .3s cubic-bezier(.2,.8,.2,1), visibility 0s; }
106
+ .insp-h { display:flex; align-items:center; justify-content:space-between; padding:0 12px 0 15px; height:39px; border-bottom:1px solid var(--rule); background:var(--panel2); font-size:10.5px; color:var(--muted); letter-spacing:.02em; flex:none; }
107
+ .insp-h button { padding:0 8px; height:24px; font-size:11px; }
108
+ .counts { display:flex; border-bottom:1px solid var(--rule); flex:none; }
109
+ .counts .cbtn { flex:1; padding:9px 6px 8px; border:0; border-right:1px solid var(--rule); border-radius:0; text-align:center; background:transparent; }
110
+ .counts .cbtn:last-child { border-right:0; } .counts .cbtn:hover { background:var(--panel2); } .counts .cbtn[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 0 -2px 0 var(--accent); } .counts .cbtn:disabled { opacity:.45; cursor:default; }
111
+ .counts .cn { font-size:20px; font-weight:600; display:block; line-height:1.1; font-variant-numeric:tabular-nums; } .cn--up { color:var(--up); } .cn--down { color:var(--down); } .counts .cl { font-size:9.5px; color:var(--muted); display:block; margin-top:3px; }
112
+ .insp-b { padding:14px 15px 18px; min-width:0; flex:1 1 auto; overflow:auto; min-height:0; }
113
+ .insp-sec { font-size:10.5px; color:var(--muted); margin:15px 0 0; padding-top:10px; border-top:1px solid var(--rule); } .insp-sec:first-child { margin-top:0; padding-top:0; border-top:0; } .insp-sec b { color:var(--fg); font-weight:600; }
114
+ .nname { font-size:15px; font-weight:600; line-height:1.25; word-break:break-word; margin:0 0 3px; } .nkind { font-size:10.5px; color:var(--muted); margin-bottom:9px; }
115
+ .pillrow { display:flex; gap:6px; margin-bottom:11px; flex-wrap:wrap; } .pill { font-size:9.5px; letter-spacing:.04em; text-transform:uppercase; padding:2px 7px; border:1px solid currentColor; }
116
+ .pill.ok { color:var(--ok); } .pill.warn { color:var(--warn); } .pill.info { color:var(--accent); } .pill.muted { color:var(--muted); }
117
+ .note { margin:0 0 12px; font-size:12.5px; color:var(--muted); line-height:1.5; } .note code, .insp code { font-family:var(--mono); font-size:11.5px; background:var(--panel2); padding:1px 4px; }
118
+ .meta { width:100%; border-collapse:collapse; font-size:11.5px; } .meta th, .meta td { text-align:left; padding:4px 0; border-top:1px solid var(--rule); vertical-align:top; } .meta th { color:var(--muted); font-weight:400; width:42%; }
119
+ .drill { margin:0 -15px; } .drill .dr { display:flex; align-items:baseline; gap:8px; width:100%; text-align:left; border:0; border-top:1px solid var(--rule); border-radius:0; background:transparent; padding:6px 15px; font:inherit; }
120
+ button.dr:hover { background:var(--panel2); } .drill .dbar { width:3px; align-self:stretch; flex:none; } .drill .dtx { min-width:0; flex:1; } .drill .dn { font-size:12.5px; display:block; word-break:break-word; line-height:1.3; } .drill .dk { font-size:10px; color:var(--muted); display:block; margin-top:1px; }
121
+ .drill-hint { font-size:10.5px; color:var(--muted); margin:10px 0 0; } .insp input[type="search"] { width:100%; margin:4px 0 6px; font-size:12px; }
122
+ .insp .acts { display:flex; gap:6px; margin:10px 0 0; flex-wrap:wrap; } .insp .acts button { font-size:11.5px; }
123
+ .ledger { margin:12px 0 0; border-top:1px solid var(--rule); padding-top:10px; } .ledger h4 { margin:0 0 6px; font-size:10.5px; color:var(--muted); font-weight:400; } .ledger p { margin:0 0 4px; font-size:12px; color:var(--muted); line-height:1.55; } .ledger p b { color:var(--fg); }
124
+ .marker { position:absolute; z-index:90; width:24px; height:24px; border-radius:50%; background:#8250df; color:#fff; font-weight:700; font-size:12px; display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 3px rgba(130,80,223,.3), var(--shadow); cursor:pointer; border:0; padding:0; } .marker:hover, .marker.on { box-shadow:0 0 0 5px rgba(130,80,223,.45); transform:scale(1.1); }
125
+ .anchored { outline:2px dashed #8250df; outline-offset:3px; }
126
+ #drawer { position:fixed; top:56px; left:0; bottom:0; width:380px; background:var(--panel); border-right:1px solid var(--edge); box-shadow:var(--shadow); z-index:80; transform:translateX(-100%); transition:transform .3s cubic-bezier(.2,.8,.2,1); overflow:auto; padding:14px 16px; } body.notes #drawer { transform:none; } main { transition:margin-left .3s cubic-bezier(.2,.8,.2,1); } body.notes main { margin-left:380px; } @media (max-width:1100px) { body.notes main { margin-left:0; } }
127
+ #drawer h2 { margin:0 0 8px; font-size:15px; } #drawer .ncard { border:1px solid var(--edge); padding:8px 10px; margin:8px 0; font-size:12.5px; } #drawer .ncard.on { border-color:#8250df; box-shadow:0 0 0 3px rgba(130,80,223,.25); }
128
+ #drawer .ncard b { display:flex; gap:8px; align-items:center; margin-bottom:2px; font-size:13px; } #drawer .ncard b span { background:#8250df; color:#fff; width:20px; height:20px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; font-size:11px; flex:none; }
129
+ details.tableview, details.spec { margin:12px 0; background:var(--panel); border:1px solid var(--edge); padding:8px 14px; } details summary { cursor:pointer; color:var(--accent); font-weight:600; }
130
+ table.sources { width:100%; border-collapse:collapse; margin-top:8px; font-size:13px; } table.sources th, table.sources td { text-align:left; padding:5px 10px; border-bottom:1px solid var(--rule); vertical-align:top; } table.sources th { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.05em; }
131
+ .spec h3 { margin:14px 0 4px; font-size:14px; } .spec h4 { margin:10px 0 2px; font-size:13px; } .spec ul { margin:4px 0 4px 18px; padding:0; } .spec li { margin:2px 0; } .spec .task { border:1px solid var(--edge); padding:6px 12px; margin:8px 0; }
132
+ .spec code { font-family:var(--mono); font-size:12px; background:var(--panel2); padding:0 4px; } .specbtns { display:flex; gap:8px; margin:8px 0; flex-wrap:wrap; }
133
+ footer { color:var(--muted); font-size:12.5px; padding:0 14px 28px; }
134
+ .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
135
+ :fullscreen.workspace { height:100vh; } .workspace.fs { position:fixed; inset:0; z-index:95; height:100vh; }
136
+ button.danger { background:var(--crit); border-color:var(--crit); color:#fff; font-weight:600; } button.danger:hover { box-shadow:0 0 0 3px var(--crit-bg); }
137
+ .layerstrip, .tiles { box-shadow:var(--shadow); } .lcell:hover { background:var(--panel2); } button.tile:hover { box-shadow:inset 0 0 0 1px var(--accent), 0 0 0 3px var(--glow); position:relative; z-index:1; }
138
+ .bandlabel:hover b { color:var(--accent); }
139
+ .node:hover { transform:translateY(-2px); }
140
+ .node.lit { border-color:var(--accent); box-shadow:0 0 0 2px var(--glow), 0 0 18px var(--glow), var(--shadow); }
141
+ .node.sel { animation:selpulse 2.4s ease-out infinite; }
142
+ .node.fail { border-color:var(--crit); background:var(--crit-bg); box-shadow:0 0 0 2px var(--crit-bg), 0 0 20px color-mix(in srgb, var(--crit) 45%, transparent); animation:failflash .9s ease-out 1; } .node.fail .s { color:var(--crit); font-weight:600; }
143
+ path.trail { fill:none; stroke:var(--accent); vector-effect:none; stroke-width:calc(3.4px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 6px var(--accent)); opacity:.95; }
144
+ path.ripple { fill:none; stroke:var(--crit); vector-effect:none; stroke-width:calc(3.6px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 7px var(--crit)); }
145
+ .intro .band { animation:fadein .6s both; } .intro .node { animation:rise .55s both; animation-delay:calc(var(--c, 0) * 60ms + 150ms); } .intro #edges { animation:fadein 1s .5s both; }
146
+ .intro .lcell, .intro .tile { animation:rise .5s both; animation-delay:calc(var(--i, 0) * 55ms); }
147
+ #viewport:focus-visible { outline-offset:-2px; }
148
+ @keyframes fadein { from { opacity:0; } to { opacity:1; } }
149
+ @keyframes flowd { to { stroke-dashoffset:-26; } } @keyframes flowc { to { stroke-dashoffset:-27; } } @keyframes flowhot { to { stroke-dashoffset:-28; } }
150
+ @keyframes selpulse { 0% { box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent), 0 0 18px var(--glow); } 80%, 100% { box-shadow:0 0 0 14px transparent, 0 0 18px var(--glow); } }
151
+ @keyframes breathe { 0%, 100% { box-shadow:0 0 0 0 transparent, var(--shadow); } 50% { box-shadow:0 0 16px 1px color-mix(in srgb, var(--breath, var(--warn)) 40%, transparent), var(--shadow); } }
152
+ @keyframes failflash { 0% { box-shadow:0 0 0 0 color-mix(in srgb, var(--crit) 60%, transparent); } 100% { box-shadow:0 0 0 16px transparent; } }
153
+ @keyframes newring { 0% { box-shadow:0 0 0 0 var(--accent); } 100% { box-shadow:0 0 0 14px transparent; } }
154
+ @keyframes flow { to { stroke-dashoffset:-28; } } @keyframes rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } @keyframes ring { to { box-shadow:0 0 0 14px transparent; } }
155
+ @keyframes beat { 0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--ok) 60%, transparent);} 70%,100%{box-shadow:0 0 0 9px transparent;} }
156
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } path.edge.hot { stroke-dasharray:none; } }
157
+ @media (max-width: 1100px) { .summary { grid-template-columns:1fr; } .workspace { height:auto; } .workspace .canvaswrap { height:75vh; } .insp { width:min(340px, 92%); } #drawer { width:100%; } .layerstrip { grid-template-columns:repeat(3,1fr) !important; } }
@@ -0,0 +1,20 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { relatives, connected } from "./trace.ts";
4
+
5
+ const edges = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "x", to: "c" }, { from: "c", to: "d" }];
6
+ const id = (s: string) => s;
7
+
8
+ test("downstream follows links forward", () => assert.deepEqual([...relatives(edges, id, "b", "down")].sort(), ["b", "c", "d"]));
9
+ test("upstream follows links backward", () => assert.deepEqual([...relatives(edges, id, "c", "up")].sort(), ["a", "b", "c", "x"]));
10
+ test("connected is up plus down", () => assert.deepEqual([...connected(edges, id, "b")].sort(), ["a", "b", "c", "d"]));
11
+ test("a chip is an alias of its real node", () => {
12
+ const canon = (s: string) => (s === "b2" ? "b" : s);
13
+ const e = [...edges, { from: "b2", to: "z" }];
14
+ assert.ok(relatives(e, canon, "b", "down").has("z"));
15
+ });
16
+ test("a cycle terminates", () => {
17
+ const cyc = [{ from: "a", to: "b" }, { from: "b", to: "a" }];
18
+ assert.deepEqual([...relatives(cyc, id, "a", "down")].sort(), ["a", "b"]);
19
+ });
20
+ test("an unknown start returns only itself", () => assert.deepEqual([...relatives(edges, id, "nope", "down")], ["nope"]));
@@ -0,0 +1,22 @@
1
+ // Path tracing over links. A portal chip is an alias of its real node: pass canonOf to map ids.
2
+
3
+ export interface Edge { from: string; to: string }
4
+
5
+ export function relatives(edges: Edge[], canonOf: (id: string) => string, start: string, dir: "up" | "down"): Set<string> {
6
+ const set = new Set<string>([canonOf(start)]);
7
+ let grew = true;
8
+ while (grew) {
9
+ grew = false;
10
+ for (const e of edges) {
11
+ const a = canonOf(e.from);
12
+ const b = canonOf(e.to);
13
+ if (dir === "up" && set.has(b) && !set.has(a)) { set.add(a); grew = true; }
14
+ if (dir === "down" && set.has(a) && !set.has(b)) { set.add(b); grew = true; }
15
+ }
16
+ }
17
+ return set;
18
+ }
19
+
20
+ export function connected(edges: Edge[], canonOf: (id: string) => string, start: string): Set<string> {
21
+ return new Set([...relatives(edges, canonOf, start, "up"), ...relatives(edges, canonOf, start, "down")]);
22
+ }
@@ -0,0 +1,55 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { zoomAt, fitRect, fitWidth, clampPan, clampK, K_MIN, K_MAX } from "./viewport.ts";
4
+
5
+ const close = (a: number, b: number, e = 1e-6) => assert.ok(Math.abs(a - b) < e, `${a} vs ${b}`);
6
+
7
+ test("zoomAt keeps the point under the cursor fixed", () => {
8
+ const v = { x: 40, y: -20, k: 0.8 }, cx = 300, cy = 200;
9
+ const w = { x: (cx - v.x) / v.k, y: (cy - v.y) / v.k };
10
+ const z = zoomAt(v, 1.7, cx, cy);
11
+ close(z.x + w.x * z.k, cx);
12
+ close(z.y + w.y * z.k, cy);
13
+ });
14
+ test("zoom is clamped to 0.25 and 2.5", () => {
15
+ assert.equal(zoomAt({ x: 0, y: 0, k: 2.4 }, 10, 0, 0).k, K_MAX);
16
+ assert.equal(zoomAt({ x: 0, y: 0, k: 0.3 }, 0.01, 0, 0).k, K_MIN);
17
+ assert.ok(Number.isFinite(clampK(NaN)));
18
+ });
19
+ test("zoomAt returns finite numbers even from a bad scale", () => {
20
+ for (const k of [0, NaN, -1]) {
21
+ const z = zoomAt({ x: 5, y: 5, k }, 1.2, 100, 100);
22
+ assert.ok([z.x, z.y, z.k].every(Number.isFinite), `k=${k}`);
23
+ }
24
+ });
25
+ test("fitRect centres, never enlarges above 1 and never goes below 0.25", () => {
26
+ const v = fitRect({ x: 0, y: 0, w: 100, h: 50 }, { w: 1000, h: 800 }, 28, 52);
27
+ assert.equal(v.k, 1);
28
+ const w = fitRect({ x: 0, y: 0, w: 100000, h: 50 }, { w: 1000, h: 800 }, 28, 52);
29
+ assert.equal(w.k, K_MIN);
30
+ });
31
+ test("fitRect gives finite numbers for a zero-size container (review focus 5)", () => {
32
+ const v = fitRect({ x: 0, y: 0, w: 500, h: 400 }, { w: 0, h: 0 }, 28, 52);
33
+ assert.ok([v.x, v.y, v.k].every(Number.isFinite));
34
+ });
35
+ test("fitRect gives finite numbers for an empty rectangle (review focus 2)", () => {
36
+ const v = fitRect({ x: 0, y: 0, w: 0, h: 0 }, { w: 1000, h: 800 }, 28, 52);
37
+ assert.ok([v.x, v.y, v.k].every(Number.isFinite));
38
+ });
39
+ test("fitWidth fits the width and starts at the top", () => {
40
+ const v = fitWidth({ x: 10, y: 20, w: 2000, h: 5000 }, { w: 1056, h: 600 }, 28, 52);
41
+ close(v.k, 1000 / 2000);
42
+ close(v.x, 28 - 10 * v.k);
43
+ close(v.y, 52 - 20 * v.k);
44
+ });
45
+ test("fitWidth is finite for an empty rectangle and a zero width", () => {
46
+ const a = fitWidth({ x: 0, y: 0, w: 0, h: 0 }, { w: 800, h: 600 }, 28, 52);
47
+ const b = fitWidth({ x: 0, y: 0, w: 900, h: 400 }, { w: 0, h: 0 }, 28, 52);
48
+ assert.ok([a.x, a.y, a.k, b.x, b.y, b.k].every(Number.isFinite));
49
+ });
50
+ test("clampPan keeps 80 px of the graph visible", () => {
51
+ const b = { x: 0, y: 0, w: 1000, h: 800 }, size = { w: 1000, h: 700 };
52
+ const far = clampPan({ x: -99999, y: 99999, k: 1 }, b, size);
53
+ assert.equal(far.x, 80 - 1000);
54
+ assert.equal(far.y, 700 - 80);
55
+ });
@@ -0,0 +1,45 @@
1
+ // View maths for the pan and zoom canvas. Pure functions, no DOM. The world is moved with
2
+ // transform: translate(x, y) scale(k); a world point (wx, wy) is at screen (x + wx * k, y + wy * k).
3
+
4
+ export interface View { x: number; y: number; k: number }
5
+ export interface Rect { x: number; y: number; w: number; h: number }
6
+ export interface Size { w: number; h: number }
7
+
8
+ export const K_MIN = 0.25;
9
+ export const K_MAX = 2.5;
10
+
11
+ export const clampK = (k: number): number => (Number.isFinite(k) ? Math.min(K_MAX, Math.max(K_MIN, k)) : 1);
12
+
13
+ /** Zoom by a factor and keep the world point under (cx, cy) fixed. */
14
+ export function zoomAt(v: View, factor: number, cx: number, cy: number): View {
15
+ const base = Number.isFinite(v.k) && v.k > 0 ? v.k : 1;
16
+ const k = clampK(base * factor);
17
+ const r = k / base;
18
+ return { k, x: cx - (cx - v.x) * r, y: cy - (cy - v.y) * r };
19
+ }
20
+
21
+ /** Fit a world rectangle into the viewport. Never enlarges above 1. `top` reserves room for pinned headers. */
22
+ export function fitRect(rect: Rect, size: Size, pad: number, top: number): View {
23
+ if (!(rect.w > 0 && rect.h > 0)) return { x: 0, y: 0, k: 1 };
24
+ const aw = Math.max(1, size.w - 2 * pad);
25
+ const ah = Math.max(1, size.h - 2 * pad - top);
26
+ const k = Math.min(1, clampK(Math.min(aw / rect.w, ah / rect.h)));
27
+ return { k, x: pad + (aw - rect.w * k) / 2 - rect.x * k, y: pad + top + (ah - rect.h * k) / 2 - rect.y * k };
28
+ }
29
+
30
+ /** Fit the width and start at the top: the readable first view. */
31
+ export function fitWidth(rect: Rect, size: Size, pad: number, top: number): View {
32
+ if (!(rect.w > 0)) return { x: 0, y: 0, k: 1 };
33
+ const k = Math.min(1, clampK((Math.max(1, size.w) - 2 * pad) / rect.w));
34
+ return { k, x: pad - rect.x * k, y: top - rect.y * k };
35
+ }
36
+
37
+ /** Keep at least 80 px of the graph on screen so the reader cannot lose it. */
38
+ export function clampPan(v: View, bounds: Rect, size: Size): View {
39
+ const keep = 80;
40
+ const minX = keep - (bounds.x + bounds.w) * v.k;
41
+ const maxX = size.w - keep - bounds.x * v.k;
42
+ const minY = keep - (bounds.y + bounds.h) * v.k;
43
+ const maxY = size.h - keep - bounds.y * v.k;
44
+ return { k: v.k, x: Math.min(maxX, Math.max(minX, v.x)), y: Math.min(maxY, Math.max(minY, v.y)) };
45
+ }