@caddis/cli 0.4.44 → 0.4.46

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 (210) 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 +92 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  57. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  60. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  92. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  93. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
  97. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
  98. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  99. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  100. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
  101. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
  102. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
  103. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  104. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  105. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  106. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
  192. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
  193. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  194. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  195. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
  196. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
  197. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
  198. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
  199. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
  200. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  201. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  202. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
  203. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
  204. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
  205. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  206. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  207. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  208. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  209. package/bundles/manifest.json +2 -2
  210. 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
+ }
@@ -0,0 +1,203 @@
1
+ // Builds the world (lanes, nodes, links) once, and paints it for the current view.
2
+ // Every model string goes through esc(), so a hostile title cannot become markup.
3
+
4
+ import { el, sv, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+ import type { NodeDef } from "./model.ts";
7
+ import { computeGeometry, DEFAULTS, type Placed } from "./layout.ts";
8
+ import { elbow, loopBack } from "./routing.ts";
9
+ import { cssId } from "./stateCss.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
+ }
25
+
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
+ }
32
+ const m = ctx.model;
33
+ const sid = nodeStateId(m, ctx.mode, n);
34
+ const word = m.states[sid]?.word ?? sid;
35
+ const bucket = bucketOf(m, sid);
36
+ const sub = n.subtitle ?? m.columns[n.col ?? 0]?.title ?? "";
37
+ const weight = Math.max(1, n.weight ?? 1);
38
+ const showWord = n.kind !== "placeholder" && n.kind !== "note";
39
+ const bar = weight > 1 && n.kind !== "chip"
40
+ ? `<div class="bar"><i class="${["good", "neutral", "gap"].includes(bucket) ? bucket : "gap"}" style="width:100%"></i></div>`
41
+ : "";
42
+ const improved = isImproved(m, ctx.mode, n) ? '<span class="cor up" title="Gap filled in this view">▲</span>' : "";
43
+ const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">↗</span>` : "";
44
+ const arrow = n.kind === "chip" ? '<span aria-hidden="true">↑ </span>' : "";
45
+ const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
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}`;
49
+ }
50
+
51
+ function build(ctx: Ctx): void {
52
+ const { model, els } = ctx;
53
+ ctx.geom = computeGeometry(model);
54
+ els.world.style.width = ctx.geom.world.w + "px";
55
+ els.world.style.height = ctx.geom.world.h + "px";
56
+ els.svg.setAttribute("width", String(ctx.geom.world.w));
57
+ els.svg.setAttribute("height", String(ctx.geom.world.h));
58
+ els.world.querySelectorAll(".node, .band, .elabel").forEach(n => n.remove());
59
+ els.layer.innerHTML = "";
60
+ ctx.nodeEls.clear(); ctx.laneEls.clear(); ctx.edgeEls = []; ctx.labelEls = [];
61
+
62
+ for (const l of model.lanes) {
63
+ const d = el("div", "band" + (l.gap ? " gap" : ""));
64
+ d.id = "lane_" + l.id;
65
+ const badge = l.badge ? `<span class="bd t-${["ok", "warn", "muted"].includes(l.badgeTone ?? "") ? l.badgeTone : "muted"}">${esc(l.badge)}</span>` : "";
66
+ const note = l.note ? `<span class="bn">${esc(l.note)}</span>` : "";
67
+ d.innerHTML = `<div class="bandlabel" role="button" tabindex="0" aria-label="${esc("Zoom to the lane " + l.title)}"><b>${esc(l.title)}</b>${badge}${note}</div>`;
68
+ const label = d.firstElementChild as HTMLElement;
69
+ on(ctx, label, "click", (e: Event) => { e.stopPropagation(); ctx.fn.zoomToBand(l.id); });
70
+ on(ctx, label, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter") ctx.fn.zoomToBand(l.id); });
71
+ els.world.insertBefore(d, els.svg.parentElement === els.world ? els.svg : null);
72
+ ctx.laneEls.set(l.id, d);
73
+ }
74
+
75
+ for (const n of model.nodes) {
76
+ const p = ctx.geom.nodes[n.id];
77
+ if (!p) continue;
78
+ const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
79
+ d.id = "n_" + n.id;
80
+ d.tabIndex = 0;
81
+ d.setAttribute("role", "button");
82
+ d.style.setProperty("--c", String(n.col ?? 0));
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";
84
+ on(ctx, d, "click", () => { if (ctx.moved > 4) return; ctx.fn.select(canonOf(ctx.nodes, n.id)); });
85
+ on(ctx, d, "dblclick", (e: Event) => { e.stopPropagation(); ctx.fn.select(canonOf(ctx.nodes, n.id)); ctx.fn.zoomToPath(); });
86
+ on(ctx, d, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ctx.fn.select(canonOf(ctx.nodes, n.id)); } });
87
+ on(ctx, d, "mouseenter", () => ctx.fn.trace(n.id));
88
+ on(ctx, d, "mouseleave", () => ctx.fn.trace(ctx.selected));
89
+ on(ctx, d, "focus", () => ctx.fn.trace(n.id));
90
+ on(ctx, d, "blur", () => ctx.fn.trace(ctx.selected));
91
+ els.world.appendChild(d);
92
+ ctx.nodeEls.set(n.id, d);
93
+ }
94
+
95
+ const gap = DEFAULTS.gap;
96
+ ctx.links.forEach((l, i) => {
97
+ const A = ctx.geom.nodes[l.from], B = ctx.geom.nodes[l.to];
98
+ if (!A || !B) { console.warn(`flow-studio: link ${l.id} skipped (an end is missing)`); return; }
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;
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;
106
+ els.layer.appendChild(path);
107
+ ctx.edgeEls[i] = path;
108
+ if (l.label) {
109
+ const lab = el("div", "elabel", esc(l.label));
110
+ lab.dataset.i = String(i); lab.dataset.a = l.from; lab.dataset.b = l.to;
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";
113
+ els.world.appendChild(lab);
114
+ ctx.labelEls[i] = lab;
115
+ }
116
+ });
117
+ }
118
+
119
+ function paint(ctx: Ctx, fromSwitch = false): void {
120
+ const { model } = ctx;
121
+ const mode = ctx.mode;
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));
127
+ document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
128
+
129
+ for (const n of model.nodes) {
130
+ const d = ctx.nodeEls.get(n.id);
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"; }
134
+ const sid = nodeStateId(model, mode, n);
135
+ const show = nodeVisible(model, mode, n, ctx.collapsed);
136
+ const bucket = bucketOf(model, sid);
137
+ const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
138
+ const fresh = isNew(model, mode, n);
139
+ d.className = "node st-" + cssId(sid)
140
+ + (n.kind === "chip" ? " ref" : "") + (n.kind === "gateway" ? " gateway" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
141
+ + (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? (cors > 1 ? " hascor2" : " hascor") : "")
142
+ + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "")
143
+ + (metricBadge(n.metrics) ? " has-metrics" : "");
144
+ d.innerHTML = nodeHtml(ctx, n);
145
+ d.setAttribute("aria-hidden", show ? "false" : "true");
146
+ d.tabIndex = show ? 0 : -1;
147
+ const word = model.states[sid]?.word ?? sid;
148
+ const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
149
+ d.setAttribute("aria-label", n.kind === "chip"
150
+ ? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.`
151
+ : `${n.title}. ${n.subtitle ?? model.columns[n.col ?? 0]?.title ?? ""}, ${word}. Press Enter to inspect.`);
152
+ if (fromSwitch && fresh && !ctx.reduced) {
153
+ d.style.transitionDelay = (n.col ?? 0) * 60 + "ms";
154
+ d.classList.add("pulse");
155
+ window.setTimeout(() => { d.style.transitionDelay = ""; d.classList.remove("pulse"); }, 2200);
156
+ } else {
157
+ d.style.transitionDelay = "";
158
+ }
159
+ }
160
+
161
+ ctx.links.forEach((l, i) => {
162
+ const p = ctx.edgeEls[i];
163
+ if (!p) return;
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"));
182
+ ctx.labelEls[i]?.classList.toggle("off", !vis);
183
+ });
184
+
185
+ for (const l of model.lanes) {
186
+ const d = ctx.laneEls.get(l.id);
187
+ const r = ctx.geom.lanes[l.id];
188
+ if (!d) continue;
189
+ d.classList.toggle("off", !r);
190
+ if (r) { d.style.left = r.x + "px"; d.style.top = r.y + "px"; d.style.width = r.w + "px"; d.style.height = r.h + "px"; }
191
+ }
192
+
193
+ ctx.fn.buildChrome();
194
+ ctx.fn.trace(ctx.selected);
195
+ ctx.fn.renderInspector();
196
+ ctx.fn.stickLabels();
197
+ ctx.fn.placeHeaders();
198
+ }
199
+
200
+ export function install(ctx: Ctx): void {
201
+ ctx.fn.buildWorld = () => build(ctx);
202
+ ctx.fn.paint = (fromSwitch?: boolean) => paint(ctx, fromSwitch);
203
+ }
@@ -0,0 +1,33 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { elbow, loopBack } from "./routing.ts";
4
+
5
+ test("same height is a straight line", () => assert.equal(elbow(0, 50, 100, 50, 50), "M0,50 H100"));
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"));
7
+ test("a large step is an elbow with a trunk at tx and rounded corners", () => {
8
+ assert.equal(elbow(0, 50, 100, 150, 40), "M0,50 H32 Q40,50 40,58 V142 Q40,150 48,150 H100");
9
+ });
10
+ test("going up mirrors the corners", () => {
11
+ assert.equal(elbow(0, 150, 100, 50, 40), "M0,150 H32 Q40,150 40,142 V58 Q40,50 48,50 H100");
12
+ });
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
+ });
@@ -0,0 +1,43 @@
1
+ // Link routing. A link leaves the right side of a node, runs to a vertical trunk at tx (half way
2
+ // across the column gap), and enters the left side of the next node. It is horizontal where it
3
+ // touches a node, so every arrowhead points right and the links of one node share one trunk.
4
+
5
+ export function elbow(x1: number, y1: number, x2: number, y2: number, tx: number): string {
6
+ const r = 8;
7
+ if (Math.abs(y2 - y1) < 1) return `M${x1},${y1} H${x2}`;
8
+ if (Math.abs(y2 - y1) < 2 * r + 2) return `M${x1},${y1} C${tx},${y1} ${tx},${y2} ${x2},${y2}`;
9
+ const dir = y2 > y1 ? 1 : -1;
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
+ }
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
+ }
@@ -0,0 +1,135 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { Model } from "./model.ts";
4
+ import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf, effectiveLinks, initialCollapsed, columnTitle, toggleGroupState } from "./rules.ts";
5
+
6
+ const m = (): Model => ({
7
+ version: 1,
8
+ meta: { title: "t", help: "", asOf: "", source: "" },
9
+ layout: "columns-lanes",
10
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
11
+ lanes: [{ id: "l1", title: "L" }],
12
+ states: {
13
+ good: { word: "GOOD", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" },
14
+ gap: { word: "GAP", tone: "warn", border: "dotted", edge: "dot", motion: "breathe", bucket: "gap" },
15
+ },
16
+ views: [{ id: "today", label: "Today" }, { id: "full", label: "Full", default: true }],
17
+ nodes: [
18
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "good", title: "A" },
19
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "good", stateIn: { today: "gap" }, title: "B" },
20
+ { id: "c", kind: "card", lane: "l1", col: 1, row: 1.8, state: "good", visibleIn: ["full"], title: "C" },
21
+ { id: "k", kind: "chip", lane: "l1", col: 0, row: 3.6, state: "good", ref: "a", title: "k" },
22
+ ],
23
+ links: [
24
+ { id: "a-b", from: "a", to: "b", state: "good" },
25
+ { id: "a-c", from: "a", to: "c", state: "good", stateIn: { today: "gap" } },
26
+ { id: "only-today", from: "a", to: "b", state: "gap", visibleIn: ["today"] },
27
+ ],
28
+ });
29
+ const byId = (mm: Model) => new Map(mm.nodes.map(n => [n.id, n]));
30
+
31
+ test("the baseline is the first view and the default is the marked one", () => {
32
+ assert.equal(baselineView(m()), "today");
33
+ assert.equal(defaultView(m()), "full");
34
+ });
35
+ test("a model with no views has one implicit view", () => {
36
+ const mm = m(); delete mm.views;
37
+ assert.equal(baselineView(mm), "all");
38
+ assert.equal(defaultView(mm), "all");
39
+ assert.equal(nodeVisible(mm, "all", mm.nodes[0]!), true);
40
+ });
41
+ test("visibleIn hides a node in other views", () => {
42
+ const mm = m();
43
+ assert.equal(nodeVisible(mm, "today", mm.nodes[2]!), false);
44
+ assert.equal(nodeVisible(mm, "full", mm.nodes[2]!), true);
45
+ });
46
+ test("stateIn overrides the state in that view only", () => {
47
+ const mm = m();
48
+ assert.equal(nodeStateId(mm, "today", mm.nodes[1]!), "gap");
49
+ assert.equal(nodeStateId(mm, "full", mm.nodes[1]!), "good");
50
+ });
51
+ test("a link is hidden when an end is hidden", () => {
52
+ const mm = m(), nodes = byId(mm);
53
+ assert.equal(linkVisible(mm, "today", mm.links[1]!, nodes), false);
54
+ assert.equal(linkVisible(mm, "full", mm.links[1]!, nodes), true);
55
+ });
56
+ test("visibleIn on a link restricts it to those views", () => {
57
+ const mm = m(), nodes = byId(mm);
58
+ assert.equal(linkVisible(mm, "today", mm.links[2]!, nodes), true);
59
+ assert.equal(linkVisible(mm, "full", mm.links[2]!, nodes), false);
60
+ });
61
+ test("a link state can differ per view", () => {
62
+ const mm = m();
63
+ assert.equal(linkStateId(mm, "today", mm.links[1]!), "gap");
64
+ assert.equal(linkStateId(mm, "full", mm.links[1]!), "good");
65
+ });
66
+ test("isNew: visible now, not in the baseline view", () => {
67
+ const mm = m();
68
+ assert.equal(isNew(mm, "full", mm.nodes[2]!), true);
69
+ assert.equal(isNew(mm, "today", mm.nodes[2]!), false);
70
+ assert.equal(isNew(mm, "full", mm.nodes[0]!), false);
71
+ });
72
+ test("isImproved: a gap in the baseline that is not a gap now", () => {
73
+ const mm = m();
74
+ assert.equal(isImproved(mm, "full", mm.nodes[1]!), true);
75
+ assert.equal(isImproved(mm, "today", mm.nodes[1]!), false);
76
+ assert.equal(isImproved(mm, "full", mm.nodes[0]!), false);
77
+ });
78
+ test("bucketOf reads the bucket of a state, and an unknown state is a gap (never a good)", () => {
79
+ const mm = m();
80
+ assert.equal(bucketOf(mm, "good"), "good");
81
+ assert.equal(bucketOf(mm, "nope"), "gap");
82
+ });
83
+ test("canonOf maps a chip to its real node and leaves others alone", () => {
84
+ const mm = m(), nodes = byId(mm);
85
+ assert.equal(canonOf(nodes, "k"), "a");
86
+ assert.equal(canonOf(nodes, "b"), "b");
87
+ assert.equal(canonOf(nodes, "missing"), "missing");
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
+ });