@caddis/cli 0.4.45 → 0.4.47

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 (193) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/flow-studio/README.md +46 -7
  3. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +81 -81
  4. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +27 -8
  14. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +177 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +385 -49
  32. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +54 -14
  35. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
  36. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
  37. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +37 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +5 -1
  57. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +11 -7
  60. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +83 -32
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +62 -2
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +66 -18
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +7 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +44 -10
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +995 -82
  92. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
  93. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +191 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  97. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
  98. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +81 -81
  99. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -0
  100. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  101. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  102. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  103. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  104. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  105. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  106. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +27 -8
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +177 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +385 -49
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +54 -14
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +37 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +5 -1
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +11 -7
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +83 -32
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +62 -2
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +66 -18
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +7 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +44 -10
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +995 -82
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +191 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  192. package/bundles/manifest.json +2 -2
  193. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ import { on, sv, type Ctx } from "./ctx.ts";
2
+ import { buildManualRoute, extendScenarioWithRoute, exitsOf, formatElapsed, planScenario, positionAt, slaState, suggestedExit, type Plan, type PlannedStep } from "./playback.ts";
3
+ import type { ScenarioDef } from "./model.ts";
4
+
5
+ export interface PlaybackController {
6
+ play(id?: string): void;
7
+ pause(): void;
8
+ step(): void;
9
+ seek(t: number): void;
10
+ playback(): { scenario: string | null; step: number; playing: boolean; t: number };
11
+ restore(state: ReturnType<PlaybackController["playback"]>): void;
12
+ reset(): void;
13
+ }
14
+
15
+ export function install(ctx: Ctx): PlaybackController {
16
+ const e = ctx.els;
17
+ const scenarios = ctx.model.scenarios ?? [];
18
+ e.playbar.hidden = !scenarios.length;
19
+ e.pbScenario.replaceChildren(...scenarios.map(s => { const o = document.createElement("option"); o.value = s.id; o.textContent = s.label; return o; }));
20
+ let scenario: ScenarioDef | undefined = scenarios[0];
21
+ let plan: Plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
22
+ let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
23
+ // Only the elements marked in the last draw are cleared again, so a frame does not walk the whole graph.
24
+ let markedEdges: SVGPathElement[] = [];
25
+ let markedNodes: HTMLElement[] = [];
26
+ const token = sv("circle", { id: "token", class: "token", r: "6" }) as SVGCircleElement;
27
+ e.layer.appendChild(token);
28
+ const pathOf = (link: string): SVGPathElement | undefined => ctx.edgeEls[ctx.links.findIndex(l => l.id === link)];
29
+ const nodeOf = (id: string) => ctx.nodeEls.get(id);
30
+ const clearMarks = () => {
31
+ for (const p of markedEdges) p.classList.remove("playing", "visited");
32
+ for (const n of markedNodes) n.classList.remove("playing", "visited");
33
+ markedEdges = []; markedNodes = [];
34
+ };
35
+ function stop(): void {
36
+ playing = false; cancelAnimationFrame(frame); clearTimeout(timer);
37
+ document.body.classList.remove("playing");
38
+ e.pbPlay.setAttribute("aria-label", "Play"); e.pbPlay.textContent = "▶";
39
+ }
40
+ function pause(): void {
41
+ if (!playing) return;
42
+ stop();
43
+ // Redraw with playing false, so the branch buttons of the step we paused on come back.
44
+ const p = positionAt(plan, t);
45
+ draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
46
+ }
47
+ function choices(step: PlannedStep | undefined): void {
48
+ if (playing || !step) {
49
+ // Hidden while playing; clear once, then leave the container alone for the rest of the frames.
50
+ if (choicesKey !== "") { e.pbChoices.replaceChildren(); choicesKey = ""; }
51
+ return;
52
+ }
53
+ const key = `${scenario?.id ?? ""}|${idx}|${plan.steps.length}|${plan.steps[idx + 1]?.link ?? ""}`;
54
+ if (key === choicesKey) return;
55
+ choicesKey = key;
56
+ e.pbChoices.replaceChildren();
57
+ const exits = exitsOf(ctx.model, step.to);
58
+ if (exits.length < 2) return;
59
+ const suggested = suggestedExit(exits, plan.steps[idx + 1]?.link);
60
+ for (const link of exits) {
61
+ const b = document.createElement("button"); b.type = "button";
62
+ b.textContent = link.label ?? ctx.nodes.get(link.to)?.title ?? link.to;
63
+ b.dataset.link = link.id;
64
+ if (link.id === suggested?.id) b.classList.add("suggested");
65
+ b.addEventListener("click", () => {
66
+ // A plan step exists only when a scenario does, so scenario is set here.
67
+ if (!scenario) return;
68
+ const route = buildManualRoute(ctx.model, link);
69
+ scenario = extendScenarioWithRoute(scenario, plan, idx, route);
70
+ plan = planScenario(ctx.model, scenario);
71
+ t = plan.steps[idx + 1]?.at ?? 0;
72
+ play();
73
+ });
74
+ e.pbChoices.appendChild(b);
75
+ }
76
+ }
77
+ function draw(stepIndex: number, progress: number): void {
78
+ const step = plan.steps[stepIndex];
79
+ clearMarks();
80
+ if (!step) { token.style.display = "none"; return; }
81
+ token.style.display = "";
82
+ for (let i = 0; i < stepIndex; i++) {
83
+ const s = plan.steps[i]!;
84
+ const p = pathOf(s.link);
85
+ if (p) { p.classList.add("visited"); markedEdges.push(p); }
86
+ const n = nodeOf(s.to);
87
+ if (n) { n.classList.add("visited"); markedNodes.push(n); }
88
+ }
89
+ const path = pathOf(step.link);
90
+ if (path) { path.classList.add("playing"); markedEdges.push(path); }
91
+ const node = nodeOf(step.to);
92
+ if (node) { node.classList.add("playing"); markedNodes.push(node); }
93
+ if (path && !path.classList.contains("off")) {
94
+ const p = path.getPointAtLength(path.getTotalLength() * progress);
95
+ token.setAttribute("cx", String(p.x)); token.setAttribute("cy", String(p.y));
96
+ } else token.style.display = "none";
97
+ e.pbCaption.textContent = step.caption;
98
+ e.pbScrub.value = String(plan.totalMs ? Math.round(t / plan.totalMs * 1000) : 0);
99
+ e.pbScrub.setAttribute("aria-valuetext", `Step ${stepIndex + 1} of ${plan.steps.length}: ${step.caption}`);
100
+ e.pbCounter.className = "";
101
+ e.pbCounter.hidden = plan.mode === "none";
102
+ if (plan.mode === "elapsed") {
103
+ const sec = scenario?.steps[step.index]?.at ?? 0;
104
+ const sla = ctx.nodes.get(step.to)?.sla;
105
+ e.pbCounter.textContent = sla === undefined ? formatElapsed(sec) : `${formatElapsed(sec)} / ${formatElapsed(sla)}`;
106
+ if (sla !== undefined) e.pbCounter.classList.add(slaState(sec, sla));
107
+ }
108
+ if (idx !== stepIndex) ctx.fn.announce(step.caption);
109
+ idx = stepIndex;
110
+ choices(step);
111
+ }
112
+ function rewind(): void {
113
+ stop(); t = 0; idx = -1; clearMarks(); choicesKey = "";
114
+ e.pbCaption.textContent = "";
115
+ e.pbCounter.hidden = true; e.pbCounter.className = "";
116
+ e.pbChoices.replaceChildren();
117
+ token.style.display = "none";
118
+ e.pbScrub.value = "0"; e.pbScrub.setAttribute("aria-valuetext", "");
119
+ }
120
+ function select(id: string): void {
121
+ scenario = scenarios.find(s => s.id === id) ?? scenarios[0];
122
+ plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
123
+ e.pbScenario.value = scenario?.id ?? "";
124
+ rewind();
125
+ }
126
+ function seek(value: number): void {
127
+ stop(); t = Math.max(0, Math.min(plan.totalMs, Number.isFinite(value) ? value : 0));
128
+ const p = positionAt(plan, t); draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
129
+ }
130
+ function tick(now: number): void {
131
+ if (!playing) return;
132
+ if (!stamp) stamp = now;
133
+ const delta = (now - stamp) * Number(e.pbSpeed.value || 1); stamp = now;
134
+ t = Math.min(plan.totalMs, t + delta);
135
+ const p = positionAt(plan, t);
136
+ if (p.done) { stop(); draw(p.stepIndex, 1); return; }
137
+ draw(p.stepIndex, p.progress);
138
+ frame = requestAnimationFrame(tick);
139
+ }
140
+ function reducedStep(): void {
141
+ if (!playing) return;
142
+ const next = Math.min(plan.steps.length - 1, idx + 1);
143
+ if (next < 0) { stop(); return; }
144
+ t = plan.steps[next]!.at + plan.steps[next]!.dur;
145
+ // Stop before the last draw so the branch buttons of the final step are built.
146
+ if (next === plan.steps.length - 1) stop();
147
+ draw(next, 1);
148
+ if (playing) timer = window.setTimeout(reducedStep, 1000 / Number(e.pbSpeed.value || 1));
149
+ }
150
+ function play(id?: string): void {
151
+ if (id && id !== scenario?.id) select(id);
152
+ // Cancel any loop already running, or two frames would advance t together.
153
+ stop();
154
+ if (!plan.steps.length) return;
155
+ if (t >= plan.totalMs) { t = 0; idx = -1; }
156
+ playing = true;
157
+ document.body.classList.add("playing");
158
+ e.pbPlay.setAttribute("aria-label", "Pause"); e.pbPlay.textContent = "❚❚";
159
+ e.pbChoices.replaceChildren(); choicesKey = "";
160
+ if (ctx.reduced) reducedStep(); else { stamp = 0; frame = requestAnimationFrame(tick); }
161
+ }
162
+ function step(): void {
163
+ stop();
164
+ const next = Math.min(idx + 1, plan.steps.length - 1);
165
+ if (next < 0) return;
166
+ t = plan.steps[next]!.at + plan.steps[next]!.dur;
167
+ draw(next, 1);
168
+ }
169
+ on(ctx, e.pbScenario, "change", () => select(e.pbScenario.value));
170
+ on(ctx, e.pbPlay, "click", () => playing ? pause() : play());
171
+ on(ctx, e.pbStep, "click", step);
172
+ // Reset rewinds the current plan (a manual branch stays); picking from the scenario list starts over.
173
+ on(ctx, e.pbReset, "click", () => rewind());
174
+ on(ctx, e.pbScrub, "input", () => seek(Number(e.pbScrub.value) / 1000 * plan.totalMs));
175
+ on(ctx, document, "keydown", (event: KeyboardEvent) => {
176
+ if (event.ctrlKey || event.metaKey || event.altKey || (event.target instanceof HTMLElement && (event.target.isContentEditable || /INPUT|TEXTAREA|SELECT/.test(event.target.tagName)))) return;
177
+ if (event.key.toLowerCase() === "p") { event.preventDefault(); playing ? pause() : play(); }
178
+ if (event.key === ".") { event.preventDefault(); step(); }
179
+ });
180
+ ctx.disposers.push(() => { stop(); token.remove(); });
181
+ select(scenario?.id ?? "");
182
+ return { play, pause, step, seek, playback: () => ({ scenario: scenario?.id ?? null, step: idx, playing, t }),
183
+ restore: state => {
184
+ if (!state.scenario || !scenarios.some(s => s.id === state.scenario)) return;
185
+ select(state.scenario);
186
+ const current = plan.steps[state.step];
187
+ if (!current) return;
188
+ t = Math.max(current.at, Math.min(current.at + current.dur, state.t));
189
+ draw(state.step, ctx.reduced ? 1 : current.dur > 0 ? (t - current.at) / current.dur : 1);
190
+ if (state.playing) play();
191
+ },
192
+ reset: () => rewind() };
193
+ }
@@ -4,17 +4,36 @@
4
4
  import { el, sv, on, type Ctx } from "./ctx.ts";
5
5
  import { esc } from "./model.ts";
6
6
  import type { NodeDef } from "./model.ts";
7
- import { computeGeometry, DEFAULTS } from "./layout.ts";
8
- import { elbow } from "./routing.ts";
7
+ import { computeGeometry, DEFAULTS, type Placed } from "./layout.ts";
8
+ import { elbow, loopBack } from "./routing.ts";
9
9
  import { cssId } from "./stateCss.ts";
10
- import { baselineView, bucketOf, canonOf, isImproved, isNew, linkStateId, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
10
+ import { baselineView, bucketOf, canonOf, effectiveLinks, isImproved, isNew, linkStateId, nodeStateId, nodeVisible } from "./rules.ts";
11
+ import { metricBadge, sparkline } from "./metrics.ts";
12
+
13
+ function loopRoute(ctx: Ctx, fromId: string, toId: string, from: Placed, to: Placed, channel: number): string {
14
+ const detourFor = (end: Placed, other: Placed) => {
15
+ const center = end.x + end.w / 2, bottom = end.y + end.h;
16
+ const blocker = ctx.model.nodes.filter(n => n.id !== fromId && n.id !== toId && nodeVisible(ctx.model, ctx.mode, n, ctx.collapsed))
17
+ .map(n => ctx.geom.nodes[n.id]).filter((p): p is Placed => !!p && p.x < center && center < p.x + p.w && p.y > bottom && p.y < channel)
18
+ .sort((a, b) => a.y - b.y)[0];
19
+ if (!blocker) return undefined;
20
+ return { x: other.x + other.w / 2 < center ? blocker.x - 10 : blocker.x + blocker.w + 10, y: (bottom + blocker.y) / 2 };
21
+ };
22
+ return loopBack(from.x + from.w / 2, from.y + from.h, to.x + to.w / 2, to.y + to.h, channel,
23
+ { from: detourFor(from, to), to: detourFor(to, from) });
24
+ }
11
25
 
12
26
  function nodeHtml(ctx: Ctx, n: NodeDef): string {
27
+ const badge = metricBadge(n.metrics);
28
+ const spark = sparkline(n.metrics, 56, 12);
29
+ if (n.kind === "gateway") {
30
+ return `<svg class="gw" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"><polygon points="50,0 100,50 50,100 0,50"></polygon></svg><span class="gw-title">${esc(n.title)}</span>${badge}${spark}`;
31
+ }
13
32
  const m = ctx.model;
14
33
  const sid = nodeStateId(m, ctx.mode, n);
15
34
  const word = m.states[sid]?.word ?? sid;
16
35
  const bucket = bucketOf(m, sid);
17
- const sub = n.subtitle ?? m.columns[n.col]?.title ?? "";
36
+ const sub = n.subtitle ?? m.columns[n.col ?? 0]?.title ?? "";
18
37
  const weight = Math.max(1, n.weight ?? 1);
19
38
  const showWord = n.kind !== "placeholder" && n.kind !== "note";
20
39
  const bar = weight > 1 && n.kind !== "chip"
@@ -24,8 +43,9 @@ function nodeHtml(ctx: Ctx, n: NodeDef): string {
24
43
  const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">↗</span>` : "";
25
44
  const arrow = n.kind === "chip" ? '<span aria-hidden="true">↑ </span>' : "";
26
45
  const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
27
- return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span></div>`
28
- + `<div class="s" title="${esc(sub)}">${lead}${esc(sub)}</div>${bar}${improved}${upgrade}`;
46
+ const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">▸ ${n.contains.length}</span>` : "";
47
+ return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div>`
48
+ + `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
29
49
  }
30
50
 
31
51
  function build(ctx: Ctx): void {
@@ -59,7 +79,7 @@ function build(ctx: Ctx): void {
59
79
  d.id = "n_" + n.id;
60
80
  d.tabIndex = 0;
61
81
  d.setAttribute("role", "button");
62
- d.style.setProperty("--c", String(n.col));
82
+ d.style.setProperty("--c", String(n.col ?? 0));
63
83
  d.style.left = p.x + "px"; d.style.top = p.y + "px"; d.style.width = p.w + "px"; d.style.height = p.h + "px";
64
84
  on(ctx, d, "click", () => { if (ctx.moved > 4) return; ctx.fn.select(canonOf(ctx.nodes, n.id)); });
65
85
  on(ctx, d, "dblclick", (e: Event) => { e.stopPropagation(); ctx.fn.select(canonOf(ctx.nodes, n.id)); ctx.fn.zoomToPath(); });
@@ -77,14 +97,19 @@ function build(ctx: Ctx): void {
77
97
  const A = ctx.geom.nodes[l.from], B = ctx.geom.nodes[l.to];
78
98
  if (!A || !B) { console.warn(`flow-studio: link ${l.id} skipped (an end is missing)`); return; }
79
99
  const x1 = A.x + A.w, y1 = A.y + A.h / 2, x2 = B.x, y2 = B.y + B.h / 2, tx = x1 + gap / 2;
80
- const path = sv("path", { d: elbow(x1, y1, x2 - 1, y2, tx), class: "edge", "data-i": String(i), "data-a": l.from, "data-b": l.to, "marker-end": "url(#arr)" }) as SVGPathElement;
100
+ const loop = l.kind === "loop-back";
101
+ const channel = ctx.geom.loops[l.id]?.channelY ?? Math.max(A.y + A.h, B.y + B.h) + 10;
102
+ const d = loop
103
+ ? loopRoute(ctx, l.from, l.to, A, B, channel)
104
+ : elbow(x1, y1, x2 - 1, y2, tx);
105
+ const path = sv("path", { d, class: "edge" + (loop ? " loop" : ""), "data-i": String(i), "data-a": l.from, "data-b": l.to, "marker-end": "url(#arr)" }) as SVGPathElement;
81
106
  els.layer.appendChild(path);
82
107
  ctx.edgeEls[i] = path;
83
108
  if (l.label) {
84
109
  const lab = el("div", "elabel", esc(l.label));
85
110
  lab.dataset.i = String(i); lab.dataset.a = l.from; lab.dataset.b = l.to;
86
- lab.style.left = (Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
87
- lab.style.top = (y1 + y2) / 2 + "px";
111
+ lab.style.left = (loop ? (A.x + A.w / 2 + B.x + B.w / 2) / 2 : Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
112
+ lab.style.top = (loop ? ctx.geom.loops[l.id]?.channelY ?? Math.max(A.y + A.h, B.y + B.h) + 10 : (y1 + y2) / 2) + "px";
88
113
  els.world.appendChild(lab);
89
114
  ctx.labelEls[i] = lab;
90
115
  }
@@ -94,21 +119,28 @@ function build(ctx: Ctx): void {
94
119
  function paint(ctx: Ctx, fromSwitch = false): void {
95
120
  const { model } = ctx;
96
121
  const mode = ctx.mode;
97
- ctx.geom = computeGeometry(model, { visible: id => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, mode, n); } });
122
+ const effective = effectiveLinks(model, mode, ctx.collapsed, ctx.nodes);
123
+ const effectiveById = new Map(effective.map(l => [l.id, l]));
124
+ ctx.geom = computeGeometry({ ...model, links: effective }, { visible: id => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, mode, n, ctx.collapsed); } });
125
+ ctx.els.world.style.height = ctx.geom.world.h + "px";
126
+ ctx.els.svg.setAttribute("height", String(ctx.geom.world.h));
98
127
  document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
99
128
 
100
129
  for (const n of model.nodes) {
101
130
  const d = ctx.nodeEls.get(n.id);
102
131
  if (!d) continue;
132
+ const rect = ctx.geom.nodes[n.id];
133
+ if (rect) { d.style.left = rect.x + "px"; d.style.top = rect.y + "px"; }
103
134
  const sid = nodeStateId(model, mode, n);
104
- const show = nodeVisible(model, mode, n);
135
+ const show = nodeVisible(model, mode, n, ctx.collapsed);
105
136
  const bucket = bucketOf(model, sid);
106
137
  const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
107
138
  const fresh = isNew(model, mode, n);
108
139
  d.className = "node st-" + cssId(sid)
109
- + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
140
+ + (n.kind === "chip" ? " ref" : "") + (n.kind === "gateway" ? " gateway" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
110
141
  + (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? (cors > 1 ? " hascor2" : " hascor") : "")
111
- + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "");
142
+ + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "")
143
+ + (metricBadge(n.metrics) ? " has-metrics" : "");
112
144
  d.innerHTML = nodeHtml(ctx, n);
113
145
  d.setAttribute("aria-hidden", show ? "false" : "true");
114
146
  d.tabIndex = show ? 0 : -1;
@@ -116,9 +148,9 @@ function paint(ctx: Ctx, fromSwitch = false): void {
116
148
  const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
117
149
  d.setAttribute("aria-label", n.kind === "chip"
118
150
  ? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.`
119
- : `${n.title}. ${n.subtitle ?? model.columns[n.col]?.title ?? ""}, ${word}. Press Enter to inspect.`);
151
+ : `${n.title}. ${n.subtitle ?? model.columns[n.col ?? 0]?.title ?? ""}, ${word}. Press Enter to inspect.`);
120
152
  if (fromSwitch && fresh && !ctx.reduced) {
121
- d.style.transitionDelay = n.col * 60 + "ms";
153
+ d.style.transitionDelay = (n.col ?? 0) * 60 + "ms";
122
154
  d.classList.add("pulse");
123
155
  window.setTimeout(() => { d.style.transitionDelay = ""; d.classList.remove("pulse"); }, 2200);
124
156
  } else {
@@ -129,8 +161,24 @@ function paint(ctx: Ctx, fromSwitch = false): void {
129
161
  ctx.links.forEach((l, i) => {
130
162
  const p = ctx.edgeEls[i];
131
163
  if (!p) return;
132
- const vis = linkVisible(model, mode, l, ctx.nodes);
133
- p.setAttribute("class", "edge st-" + cssId(linkStateId(model, mode, l)) + (vis ? "" : " off"));
164
+ const edge = effectiveById.get(l.id);
165
+ const vis = !!edge;
166
+ const a = edge && ctx.geom.nodes[edge.from], b = edge && ctx.geom.nodes[edge.to];
167
+ const loop = edge?.kind === "loop-back";
168
+ if (a && b) {
169
+ p.dataset.a = edge!.from; p.dataset.b = edge!.to;
170
+ const channel = ctx.geom.loops[edge!.id]?.channelY ?? Math.max(a.y + a.h, b.y + b.h) + 10;
171
+ p.setAttribute("d", loop
172
+ ? loopRoute(ctx, edge!.from, edge!.to, a, b, channel)
173
+ : elbow(a.x + a.w, a.y + a.h / 2, b.x - 1, b.y + b.h / 2, a.x + a.w + DEFAULTS.gap / 2));
174
+ const lab = ctx.labelEls[i];
175
+ if (lab) {
176
+ lab.dataset.a = edge!.from; lab.dataset.b = edge!.to;
177
+ lab.style.left = (loop ? (a.x + a.w / 2 + b.x + b.w / 2) / 2 : Math.abs(b.y + b.h / 2 - a.y - a.h / 2) < 1 ? (a.x + a.w + b.x) / 2 : a.x + a.w + DEFAULTS.gap / 2) + "px";
178
+ lab.style.top = (loop ? channel : (a.y + a.h / 2 + b.y + b.h / 2) / 2) + "px";
179
+ }
180
+ }
181
+ p.setAttribute("class", "edge st-" + cssId(edge?.state ?? linkStateId(model, mode, l)) + (loop ? " loop" : "") + (vis ? "" : " off"));
134
182
  ctx.labelEls[i]?.classList.toggle("off", !vis);
135
183
  });
136
184
 
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { elbow } from "./routing.ts";
3
+ import { elbow, loopBack } from "./routing.ts";
4
4
 
5
5
  test("same height is a straight line", () => assert.equal(elbow(0, 50, 100, 50, 50), "M0,50 H100"));
6
6
  test("a small vertical step is a gentle S curve", () => assert.equal(elbow(0, 50, 100, 60, 50), "M0,50 C50,50 50,60 100,60"));
@@ -11,3 +11,23 @@ test("going up mirrors the corners", () => {
11
11
  assert.equal(elbow(0, 150, 100, 50, 40), "M0,150 H32 Q40,150 40,142 V58 Q40,50 48,50 H100");
12
12
  });
13
13
  test("every number in a path is finite", () => assert.ok(!/NaN|Infinity/.test(elbow(0, 0, 10, 300, 5))));
14
+ test("loop-back leaves and enters bottom edges for either horizontal direction", () => {
15
+ for (const [x1, x2] of [[100, 20], [20, 100], [20, 20]]) {
16
+ const p = loopBack(x1, 40, x2, 50, 90);
17
+ assert.match(p, new RegExp(`^M${x1},40\\b`));
18
+ assert.match(p, new RegExp(`50$`));
19
+ assert.ok(!/NaN|Infinity/.test(p));
20
+ assert.match(p, /Q/);
21
+ }
22
+ });
23
+ test("loop-back clamps its corner radius for short channels", () => {
24
+ const p = loopBack(10, 20, 10, 20, 23);
25
+ assert.ok(!/NaN|Infinity/.test(p));
26
+ assert.match(p, /^M10,20/);
27
+ });
28
+ test("a loop-back can detour around a node below its source", () => {
29
+ const path = loopBack(100, 50, 20, 50, 120, { from: { x: 60, y: 54 } });
30
+ assert.match(path, /^M100,50 V54 H60 V/);
31
+ assert.match(path, /V50$/);
32
+ assert.ok(!/NaN|Infinity/.test(path));
33
+ });
@@ -9,3 +9,35 @@ export function elbow(x1: number, y1: number, x2: number, y2: number, tx: number
9
9
  const dir = y2 > y1 ? 1 : -1;
10
10
  return `M${x1},${y1} H${tx - r} Q${tx},${y1} ${tx},${y1 + dir * r} V${y2 - dir * r} Q${tx},${y2} ${tx + r},${y2} H${x2}`;
11
11
  }
12
+
13
+ /** A return path leaves and enters the bottom of its nodes. The last segment points up. */
14
+ export function loopBack(x1: number, y1: number, x2: number, y2: number, channelY: number,
15
+ detour: { from?: { x: number; y: number }; to?: { x: number; y: number } } = {}): string {
16
+ // Validated models supply finite positions. Guard the pure helper too so malformed input never
17
+ // produces a path containing NaN or Infinity.
18
+ x1 = Number.isFinite(x1) ? x1 : 0;
19
+ x2 = Number.isFinite(x2) ? x2 : 0;
20
+ y1 = Number.isFinite(y1) ? y1 : 0;
21
+ y2 = Number.isFinite(y2) ? y2 : 0;
22
+ channelY = Number.isFinite(channelY) ? channelY : Math.max(y1, y2) + 10;
23
+ channelY = Math.max(channelY, y1, y2);
24
+ const from = detour.from, to = detour.to;
25
+ const startX = from?.x ?? x1, endX = to?.x ?? x2;
26
+ const start = from ? `M${x1},${y1} V${from.y} H${startX}` : `M${x1},${y1}`;
27
+ const finish = to ? ` V${to.y} H${x2} V${y2}` : ` V${y2}`;
28
+ if (from || to) {
29
+ const dir = endX >= startX ? 1 : -1;
30
+ const r = Math.max(0, Math.min(8, Math.abs(endX - startX) / 2, (channelY - (from?.y ?? y1)) / 2, (channelY - (to?.y ?? y2)) / 2));
31
+ return `${start} V${channelY - r} Q${startX},${channelY} ${startX + dir * r},${channelY} H${endX - dir * r} Q${endX},${channelY} ${endX},${channelY - r}${finish}`;
32
+ }
33
+ const dx = x2 - x1;
34
+ const dir = dx >= 0 ? 1 : -1;
35
+ const run = Math.abs(dx) || 32;
36
+ const radius = Math.min(8, run / 2, (channelY - y1) / 2, (channelY - y2) / 2);
37
+ const r = Math.max(0, radius);
38
+ if (dx === 0) {
39
+ // Two gentle opposite turns give a finite return even for vertically aligned boxes.
40
+ return `M${x1},${y1} V${channelY - r} Q${x1},${channelY} ${x1 + r},${channelY} H${x1 + 16} H${x1 - r} Q${x1},${channelY} ${x1},${channelY - r} V${y2}`;
41
+ }
42
+ return `M${x1},${y1} V${channelY - r} Q${x1},${channelY} ${x1 + dir * r},${channelY} H${x2 - dir * r} Q${x2},${channelY} ${x2},${channelY - r} V${y2}`;
43
+ }
@@ -1,7 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import type { Model } from "./model.ts";
4
- import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf } from "./rules.ts";
4
+ import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf, effectiveLinks, initialCollapsed, columnTitle, toggleGroupState } from "./rules.ts";
5
5
 
6
6
  const m = (): Model => ({
7
7
  version: 1,
@@ -86,3 +86,50 @@ test("canonOf maps a chip to its real node and leaves others alone", () => {
86
86
  assert.equal(canonOf(nodes, "b"), "b");
87
87
  assert.equal(canonOf(nodes, "missing"), "missing");
88
88
  });
89
+ test("collapsed groups replace members and merge duplicate links with the worst state", () => {
90
+ const mm = m();
91
+ mm.nodes.push({ id: "group", kind: "group", lane: "l1", col: 0, row: 5, state: "good", title: "Group", contains: ["a", "b"] });
92
+ mm.links.push({ id: "b-c", from: "b", to: "c", state: "gap" });
93
+ mm.links.push({ id: "a-b", from: "a", to: "b", state: "good" });
94
+ const collapsed = new Set(["group"]), nodes = byId(mm);
95
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), false);
96
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "group")!, collapsed), true);
97
+ const links = effectiveLinks(mm, "full", collapsed, nodes);
98
+ assert.equal(links.filter(l => l.from === "group" && l.to === "c").length, 1);
99
+ assert.equal(links.find(l => l.from === "group" && l.to === "c")?.state, "gap");
100
+ assert.ok(!links.some(l => l.from === l.to));
101
+ collapsed.clear();
102
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), true);
103
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "group")!, collapsed), false);
104
+ });
105
+
106
+ test("groups with contains start collapsed unless collapsed is false", () => {
107
+ const mm = m();
108
+ mm.nodes.push({ id: "default", kind: "group", lane: "l1", state: "good", title: "Default", contains: ["a"] });
109
+ mm.nodes.push({ id: "expanded", kind: "group", lane: "l1", state: "good", title: "Expanded", contains: ["b"], collapsed: false });
110
+ const collapsed = initialCollapsed(mm);
111
+ assert.deepEqual([...collapsed], ["default"]);
112
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), false);
113
+ assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "b")!, collapsed), true);
114
+ });
115
+ test("a free node with no column has an empty table column", () => {
116
+ const mm = m(); mm.layout = "free";
117
+ delete mm.nodes[0]!.col;
118
+ assert.equal(columnTitle(mm, mm.nodes[0]!), "");
119
+ assert.equal(columnTitle(mm, mm.nodes[1]!), "B");
120
+ });
121
+ test("toggleGroup selects a visible child on expansion and its group on collapse", () => {
122
+ const mm = m();
123
+ mm.nodes.push({ id: "group", kind: "group", lane: "l1", state: "good", title: "Group", contains: ["a", "b"] });
124
+ const starting = new Set(["group"]);
125
+ const expanded = toggleGroupState(mm, "full", starting, "group")!;
126
+ assert.equal(expanded.expanding, true);
127
+ assert.equal(expanded.selected, "a");
128
+ assert.equal(expanded.collapsed.has("group"), false);
129
+ assert.equal(starting.has("group"), true);
130
+ const folded = toggleGroupState(mm, "full", expanded.collapsed, "group")!;
131
+ assert.equal(folded.expanding, false);
132
+ assert.equal(folded.selected, "group");
133
+ assert.equal(folded.collapsed.has("group"), true);
134
+ assert.equal(toggleGroupState(mm, "full", starting, "missing"), null);
135
+ });
@@ -7,7 +7,35 @@ import type { Model, NodeDef, LinkDef, Bucket } from "./model.ts";
7
7
  export const baselineView = (m: Model): string => m.views?.[0]?.id ?? "all";
8
8
  export const defaultView = (m: Model): string => (m.views?.find(v => v.default) ?? m.views?.[0])?.id ?? "all";
9
9
 
10
- export const nodeVisible = (_m: Model, view: string, n: NodeDef): boolean => !n.visibleIn || n.visibleIn.includes(view);
10
+ /** A group that contains nodes starts folded unless it explicitly opts out. */
11
+ export const initialCollapsed = (m: Model): Set<string> => new Set(m.nodes
12
+ .filter(n => n.kind === "group" && n.contains && n.collapsed !== false).map(n => n.id));
13
+ export const columnTitle = (m: Model, n: NodeDef): string => n.col === undefined ? "" : m.columns[n.col]?.title ?? "";
14
+
15
+ export function groupFor(m: Model, nodeId: string): NodeDef | undefined {
16
+ return m.nodes.find(n => n.kind === "group" && n.contains?.includes(nodeId));
17
+ }
18
+
19
+ export const nodeVisible = (m: Model, view: string, n: NodeDef, collapsed?: Set<string>): boolean => {
20
+ if (n.visibleIn && !n.visibleIn.includes(view)) return false;
21
+ if (!collapsed) return true;
22
+ if (n.kind === "group" && n.contains) return collapsed.has(n.id);
23
+ const owner = groupFor(m, n.id);
24
+ return !owner || !collapsed.has(owner.id);
25
+ };
26
+ export function toggleGroupState(m: Model, view: string, collapsed: Set<string>, id: string):
27
+ { collapsed: Set<string>; selected: string | null; expanding: boolean } | null {
28
+ const group = m.nodes.find(n => n.id === id && n.kind === "group" && n.contains?.length);
29
+ if (!group) return null;
30
+ const next = new Set(collapsed);
31
+ const expanding = next.has(id);
32
+ if (expanding) next.delete(id); else next.add(id);
33
+ const selected = expanding ? group.contains?.find(child => {
34
+ const n = m.nodes.find(candidate => candidate.id === child);
35
+ return !!n && nodeVisible(m, view, n, next);
36
+ }) ?? null : id;
37
+ return { collapsed: next, selected, expanding };
38
+ }
11
39
  export const nodeStateId = (_m: Model, view: string, n: NodeDef): string => n.stateIn?.[view] ?? n.state;
12
40
 
13
41
  export function linkVisible(m: Model, view: string, l: LinkDef, nodes: Map<string, NodeDef>): boolean {
@@ -33,3 +61,26 @@ export const isImproved = (m: Model, view: string, n: NodeDef): boolean => {
33
61
 
34
62
  /** A portal chip is an alias of the node it points at. */
35
63
  export const canonOf = (nodes: Map<string, NodeDef>, id: string): string => nodes.get(id)?.ref ?? id;
64
+
65
+ /** Map hidden members to their visible group and merge parallel effective links. */
66
+ export function effectiveLinks(m: Model, view: string, collapsed: Set<string>, nodes: Map<string, NodeDef>): LinkDef[] {
67
+ const byPair = new Map<string, LinkDef>();
68
+ const rank = { good: 0, neutral: 1, gap: 2 };
69
+ for (const link of m.links) {
70
+ if (link.visibleIn && !link.visibleIn.includes(view)) continue;
71
+ const a = nodes.get(link.from), b = nodes.get(link.to);
72
+ if (!a || !b || (a.visibleIn && !a.visibleIn.includes(view)) || (b.visibleIn && !b.visibleIn.includes(view))) continue;
73
+ const groupA = groupFor(m, a.id), groupB = groupFor(m, b.id);
74
+ const from = groupA && collapsed.has(groupA.id) ? groupA.id : a.id;
75
+ const to = groupB && collapsed.has(groupB.id) ? groupB.id : b.id;
76
+ if (from === to) continue;
77
+ const fromNode = nodes.get(from), toNode = nodes.get(to);
78
+ if (!fromNode || !toNode || !nodeVisible(m, view, fromNode, collapsed) || !nodeVisible(m, view, toNode, collapsed)) continue;
79
+ const state = linkStateId(m, view, link);
80
+ const key = JSON.stringify([from, to]);
81
+ const prev = byPair.get(key);
82
+ if (!prev) byPair.set(key, { ...link, from, to, state, stateIn: undefined });
83
+ else if (rank[bucketOf(m, state)] > rank[bucketOf(m, prev.state)]) prev.state = state;
84
+ }
85
+ return [...byPair.values()];
86
+ }
@@ -0,0 +1,12 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { notifySelection } from "./selection.ts";
4
+
5
+ test("selection callback reports changes and clears once", () => {
6
+ const seen: Array<string | null> = [];
7
+ const notify = (id: string | null) => seen.push(id);
8
+ notifySelection(null, "a", notify);
9
+ notifySelection("a", "a", notify);
10
+ notifySelection("a", null, notify);
11
+ assert.deepEqual(seen, ["a", null]);
12
+ });
@@ -0,0 +1,4 @@
1
+ /** Report only a real selection change. */
2
+ export function notifySelection(previous: string | null, next: string | null, onSelect?: (id: string | null) => void): void {
3
+ if (previous !== next) onSelect?.(next);
4
+ }
@@ -21,6 +21,13 @@ test("each state gets a node rule, a word rule and an edge rule", () => {
21
21
  assert.ok(css.includes(`path.edge.st-${id}`), `edge ${id}`);
22
22
  }
23
23
  });
24
+ test("gateway polygons receive each state's stroke and tint", () => {
25
+ const css = stateCss(states);
26
+ for (const id of Object.keys(states)) {
27
+ assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*stroke:var\\(--`));
28
+ assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*fill:color-mix\\(`));
29
+ }
30
+ });
24
31
  test("a dotted state is a tinted card with a dotted border", () => {
25
32
  const css = stateCss(states);
26
33
  assert.match(css, /\.node\.st-gap\s*\{[^}]*dotted/);
@@ -26,6 +26,7 @@ export function stateCss(states: Model["states"]): string {
26
26
  const glow = s.bucket === "good" ? ` box-shadow:0 0 14px -4px color-mix(in srgb, ${T} 55%, transparent), var(--shadow);` : "";
27
27
  out.push(`.node.st-${c} { border-left:4px ${s.border} ${T};${glow} --breath:${T}; }`);
28
28
  }
29
+ out.push(`.node.st-${c}.gateway polygon { stroke:${T}; fill:color-mix(in srgb, ${T} 10%, var(--panel)); }`);
29
30
  out.push(`.ev.st-${c} { color:${T}; }`);
30
31
  if (s.motion === "breathe") out.push(`.node.st-${c}:not(.dim) { animation:breathe 3.2s ease-in-out infinite; }`);
31
32