@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
@@ -2,17 +2,22 @@
2
2
  // a small API (also placed on window.FlowStudio for the check harness). The generated demo page calls mount() itself.
3
3
 
4
4
  import { validateModel, type Model, type NoteDef } from "./model.ts";
5
+ import { autoLayout } from "./autolayout.ts";
5
6
  import type { Ctx, Els } from "./ctx.ts";
6
7
  import type { View } from "./viewport.ts";
7
8
  import type { Geometry } from "./layout.ts";
8
9
  import { stateCss } from "./stateCss.ts";
9
- import { defaultView } from "./rules.ts";
10
+ import { defaultView, initialCollapsed } from "./rules.ts";
10
11
  import { install as installInteract } from "./interact.ts";
11
12
  import { install as installRender } from "./render.ts";
12
13
  import { install as installControls } from "./controls.ts";
13
14
  import { install as installInspector } from "./inspector.ts";
14
15
  import { install as installChrome } from "./chrome.ts";
15
16
  import { install as installNotes } from "./notes.ts";
17
+ import { install as installPlayback, type PlaybackController } from "./playbackui.ts";
18
+ import { diffModels } from "./diff.ts";
19
+ import { notifySelection } from "./selection.ts";
20
+ import type { Issue } from "./model.ts";
16
21
 
17
22
  export interface FlowStudio {
18
23
  model: Model;
@@ -26,6 +31,12 @@ export interface FlowStudio {
26
31
  setMode(id: string): void;
27
32
  simulate(actionId: string): void;
28
33
  resetSimulation(): void;
34
+ play(scenarioId?: string): void;
35
+ pause(): void;
36
+ step(): void;
37
+ seek(tMs: number): void;
38
+ playback(): { scenario: string | null; step: number; playing: boolean; t: number };
39
+ update(model: Model, opts?: { animate?: boolean }): { ok: boolean; issues: Issue[] };
29
40
  destroy(): void;
30
41
  }
31
42
 
@@ -36,9 +47,11 @@ declare global {
36
47
  const IDS: Record<keyof Els, string> = {
37
48
  vp: "viewport", world: "world", svg: "edges", layer: "edgeLayer", cols: "cols", tools: "tools", zoom: "zoomLabel",
38
49
  inspector: "inspector", workspace: "workspace", live: "live", strip: "layerstrip", tiles: "kpis", legend: "legend",
39
- ledger: "ledger", table: "tbl", q: "q", matchinfo: "matchinfo", modeSwitch: "modeSwitch", drawer: "drawer",
50
+ ledger: "ledger", table: "tbl", q: "q", hops: "hops", matchinfo: "matchinfo", modeSwitch: "modeSwitch", drawer: "drawer",
40
51
  notelist: "notelist", notesBtn: "notesBtn", theme: "theme", specbody: "specbody", spec: "spec", title: "title",
41
- readat: "readat", help: "help", footer: "footer",
52
+ readat: "readat", help: "help", footer: "footer", playbar: "playbar", pbScenario: "pbScenario", pbPlay: "pbPlay",
53
+ pbStep: "pbStep", pbReset: "pbReset", pbSpeed: "pbSpeed", pbScrub: "pbScrub", pbCaption: "pbCaption",
54
+ pbCounter: "pbCounter", pbChoices: "pbChoices", banner: "banner",
42
55
  };
43
56
 
44
57
  function findEls(root: ParentNode): Els {
@@ -51,10 +64,12 @@ function findEls(root: ParentNode): Els {
51
64
  return out as unknown as Els;
52
65
  }
53
66
 
54
- export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[] } = {}): FlowStudio {
67
+ interface SavedState { view: View; mode: string; selected: string | null; inspectorOpen: boolean; query: string; hops: number; filter: Ctx["filter"]; collapsed: Set<string>; knownGroups: Set<string>; playback: ReturnType<PlaybackController["playback"]> }
68
+ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; restore?: SavedState } = {}): FlowStudio {
55
69
  if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
56
70
  throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
57
71
  }
72
+ if (model.layout === "auto") model = autoLayout(model);
58
73
  const els = findEls(root);
59
74
  const issues = validateModel(model);
60
75
  const errors = issues.filter(i => i.level === "error");
@@ -63,15 +78,17 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
63
78
 
64
79
  const rm = matchMedia("(prefers-reduced-motion: reduce)");
65
80
  const ctx: Ctx = {
66
- model, root, els,
81
+ model, root, els, onSelect: opts.onSelect,
67
82
  notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
68
83
  nodes: new Map(model.nodes.map(n => [n.id, n])),
69
84
  links: model.links,
70
- geom: { xs: [], nodes: {}, lanes: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
85
+ geom: { xs: [], nodes: {}, lanes: {}, loops: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
71
86
  view: { x: 0, y: 0, k: 1 },
72
87
  reduced: rm.matches,
73
- mode: defaultView(model),
74
- selected: null, hl: null, query: "", filter: null, tool: "select",
88
+ mode: opts.restore && (model.views?.some(v => v.id === opts.restore!.mode) || (!model.views?.length && opts.restore.mode === "all")) ? opts.restore.mode : defaultView(model),
89
+ collapsed: opts.restore ? new Set(model.nodes.filter(n => n.kind === "group" && n.contains && (opts.restore!.collapsed.has(n.id) || (!opts.restore!.knownGroups.has(n.id) && n.collapsed !== false))).map(n => n.id)) : initialCollapsed(model),
90
+ selected: opts.restore?.selected && model.nodes.some(n => n.id === opts.restore!.selected) ? opts.restore.selected : null,
91
+ hl: null, query: opts.restore?.query ?? "", hops: opts.restore?.hops ?? 0, filter: opts.restore?.filter ?? null, tool: "select",
75
92
  spaceDown: false, userMoved: false, dismissed: false, simActive: false, moved: 0,
76
93
  nodeEls: new Map(), laneEls: new Map(), edgeEls: [], labelEls: [], overlays: [], simTimers: [], disposers: [],
77
94
  fn: {} as Ctx["fn"],
@@ -90,13 +107,36 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
90
107
  installInspector(ctx);
91
108
  installChrome(ctx);
92
109
  installNotes(ctx);
110
+ els.q.value = ctx.query;
111
+ els.hops.value = String(ctx.hops);
93
112
 
94
- document.body.classList.add("intro");
95
- window.setTimeout(() => document.body.classList.remove("intro"), 2100);
96
113
  ctx.fn.buildWorld();
97
114
  ctx.fn.paint(false);
98
- ctx.fn.fitWidth(false);
99
- window.setTimeout(() => ctx.fn.fitWidth(false), 120);
115
+ const playback = installPlayback(ctx);
116
+ if (opts.restore) {
117
+ ctx.userMoved = true;
118
+ ctx.fn.setView(opts.restore.view, false);
119
+ playback.restore(opts.restore.playback);
120
+ if (opts.restore.inspectorOpen) {
121
+ els.inspector.classList.add("open");
122
+ els.workspace.classList.add("inspopen");
123
+ els.inspector.setAttribute("aria-hidden", "false");
124
+ ctx.fn.renderInspector();
125
+ } else {
126
+ els.inspector.classList.remove("open");
127
+ els.workspace.classList.remove("inspopen");
128
+ els.inspector.setAttribute("aria-hidden", "true");
129
+ }
130
+ } else {
131
+ // The entrance animation is for the first mount only; a live update restores and must not replay it.
132
+ document.body.classList.add("intro");
133
+ const introTimer = window.setTimeout(() => document.body.classList.remove("intro"), 2100);
134
+ // destroy() clears these timers, so an update inside the window cannot fire the old instance's callbacks.
135
+ ctx.disposers.push(() => { window.clearTimeout(introTimer); document.body.classList.remove("intro"); });
136
+ ctx.fn.fitWidth(false);
137
+ const fitTimer = window.setTimeout(() => { if (!ctx.userMoved) ctx.fn.fitWidth(false); }, 120);
138
+ ctx.disposers.push(() => window.clearTimeout(fitTimer));
139
+ }
100
140
 
101
141
  const api: FlowStudio = {
102
142
  model,
@@ -110,33 +150,44 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
110
150
  setMode: id => ctx.fn.setMode(id),
111
151
  simulate: id => ctx.fn.simulate(id),
112
152
  resetSimulation: () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); },
153
+ play: id => playback.play(id),
154
+ pause: () => playback.pause(),
155
+ step: () => playback.step(),
156
+ seek: t => playback.seek(t),
157
+ playback: () => playback.playback(),
158
+ update: (nextModel, updateOpts) => {
159
+ const prepared = nextModel.layout === "auto" ? autoLayout(nextModel) : nextModel;
160
+ const nextIssues = validateModel(prepared);
161
+ const count = nextIssues.filter(i => i.level === "error").length;
162
+ if (count) {
163
+ els.banner.textContent = `Update failed: ${count} errors. Showing the last good graph.`;
164
+ els.banner.hidden = false;
165
+ return { ok: false, issues: nextIssues };
166
+ }
167
+ const changes = diffModels(ctx.model, prepared);
168
+ const saved: SavedState = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter(n => n.kind === "group").map(n => n.id)), playback: playback.playback() };
169
+ api.destroy();
170
+ const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
171
+ notifySelection(saved.selected, replacement.selected(), opts.onSelect);
172
+ // Refresh this api in place: callers keep their reference and window.FlowStudio stays this
173
+ // instance, so an update can never overwrite a different mounted engine.
174
+ Object.assign(api, replacement);
175
+ window.FlowStudio = api;
176
+ els.banner.hidden = true; els.banner.textContent = "";
177
+ if (updateOpts?.animate !== false && !ctx.reduced) {
178
+ for (const id of changes.changed) root.querySelector<HTMLElement>(`#n_${CSS.escape(id)}`)?.classList.add("updated");
179
+ for (const id of changes.added) root.querySelector<HTMLElement>(`#n_${CSS.escape(id)}`)?.classList.add("added");
180
+ }
181
+ return { ok: true, issues: nextIssues };
182
+ },
113
183
  destroy: () => {
114
184
  ctx.fn.clearSim();
115
185
  ctx.disposers.forEach(d => d());
116
186
  ctx.disposers = [];
117
187
  style?.remove();
118
- if (window.FlowStudio === api) delete window.FlowStudio;
188
+ if (window.FlowStudio === api || window.FlowStudio?.model === api.model) delete window.FlowStudio;
119
189
  },
120
190
  };
121
191
  window.FlowStudio = api;
122
192
  return api;
123
193
  }
124
-
125
- function auto(): void {
126
- const m = document.getElementById("flow-model");
127
- if (!m) return;
128
- try {
129
- const model = JSON.parse(m.textContent ?? "{}") as Model;
130
- const n = document.getElementById("flow-notes");
131
- const notes = n ? (JSON.parse(n.textContent ?? "[]") as NoteDef[]) : undefined;
132
- mount(document, model, { notes });
133
- } catch (e) {
134
- console.error(e);
135
- const h = document.getElementById("help");
136
- if (h) h.textContent = "This page could not be drawn: " + (e instanceof Error ? e.message : String(e));
137
- }
138
- }
139
-
140
- window.flowStudioMount = mount;
141
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", auto);
142
- else auto();
@@ -5,8 +5,9 @@
5
5
  import type { Ctx } from "./ctx.ts";
6
6
  import { esc } from "./model.ts";
7
7
  import type { NodeDef, RelationDef } from "./model.ts";
8
- import { bucketOf, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
8
+ import { bucketOf, effectiveLinks, groupFor, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
9
9
  import { matches, relationSet, relationsOf } from "./interact.ts";
10
+ import { sparklinePoints } from "./metrics.ts";
10
11
 
11
12
  const BUCKET_COLOR = { good: "ok", neutral: "muted", gap: "warn" } as const;
12
13
  const PILL = { ok: "ok", warn: "warn", crit: "warn", info: "info", accent: "info", muted: "muted" } as const;
@@ -29,9 +30,10 @@ export function install(ctx: Ctx): void {
29
30
  }));
30
31
  box.querySelectorAll<HTMLElement>("[data-g]").forEach(b => b.addEventListener("click", () => { ctx.fn.select(b.dataset.g ?? null); ctx.fn.zoomToPath(); }));
31
32
  box.querySelectorAll<HTMLElement>("[data-act]").forEach(b => b.addEventListener("click", () => ctx.fn.simulate(b.dataset.act ?? "")));
33
+ box.querySelectorAll<HTMLElement>("[data-toggle-group]").forEach(b => b.addEventListener("click", () => ctx.fn.toggleGroup(b.dataset.toggleGroup ?? "")));
32
34
  box.querySelector("#simReset")?.addEventListener("click", () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); ctx.fn.announce("Simulation reset."); });
33
35
  box.querySelector("#zPath")?.addEventListener("click", () => ctx.fn.zoomToPath());
34
- box.querySelector("#zBand")?.addEventListener("click", () => { const n = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined; if (n) ctx.fn.zoomToBand(n.lane); });
36
+ box.querySelector("#zBand")?.addEventListener("click", () => { const n = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined; if (n?.lane) ctx.fn.zoomToBand(n.lane); });
35
37
  }
36
38
 
37
39
  function renderInspector(): void {
@@ -40,7 +42,7 @@ export function install(ctx: Ctx): void {
40
42
  const head = `<div class="insp-h"><span>Inspector</span><button type="button" id="hideInsp" aria-label="Close the inspector">Close ✕</button></div>`;
41
43
 
42
44
  if (!sel) {
43
- const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n) && matches(ctx, n)) : [];
45
+ const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed) && matches(ctx, n)) : [];
44
46
  const open = found.length > 0;
45
47
  box.classList.toggle("open", open);
46
48
  ws.classList.toggle("inspopen", open);
@@ -49,7 +51,7 @@ export function install(ctx: Ctx): void {
49
51
  const rows = found.map(f => {
50
52
  const b = bucketOf(model, nodeStateId(model, ctx.mode, f));
51
53
  const hits = (f.members ?? []).filter(m => m.toLowerCase().includes(ctx.query)).length;
52
- return `<button type="button" class="dr" data-g="${esc(f.id)}"><span class="dbar" style="background:var(--${BUCKET_COLOR[b]})"></span><span class="dtx"><span class="dn">${esc(f.title)}</span><span class="dk">${hits} match · ${esc(model.columns[f.col]?.title ?? "")}</span></span></button>`;
54
+ return `<button type="button" class="dr" data-g="${esc(f.id)}"><span class="dbar" style="background:var(--${BUCKET_COLOR[b]})"></span><span class="dtx"><span class="dn">${esc(f.title)}</span><span class="dk">${hits} match · ${esc(model.columns[f.col ?? 0]?.title ?? "")}</span></span></button>`;
53
55
  }).join("");
54
56
  box.innerHTML = head + `<div class="insp-b"><div class="insp-sec"><b>${found.length}</b> group${found.length === 1 ? "" : "s"} match “${esc(ctx.query)}”</div><div class="drill">${rows}</div></div>`;
55
57
  wire(box);
@@ -82,11 +84,27 @@ export function install(ctx: Ctx): void {
82
84
  + (isNew(model, ctx.mode, sel) ? '<span class="pill muted">new in this view</span>' : "");
83
85
  const actions = (model.inspector?.actions ?? []).map(a => `<button type="button" class="danger" data-act="${esc(a.id)}">▶ ${esc(a.label)}</button>`).join("");
84
86
  const members = sel.members ?? [];
87
+ const owner = groupFor(model, sel.id);
88
+ const foldAction = sel.kind === "group" && sel.contains?.length && ctx.collapsed.has(sel.id)
89
+ ? `<button type="button" data-toggle-group="${esc(sel.id)}">Expand group</button>`
90
+ : owner && !ctx.collapsed.has(owner.id)
91
+ ? `<button type="button" data-toggle-group="${esc(owner.id)}">Collapse into ${esc(owner.title)}</button>` : "";
92
+ const exits = sel.kind === "gateway" ? effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes).filter(l => l.from === sel.id).map(l => {
93
+ const target = ctx.nodes.get(l.to);
94
+ return `<div class="dr"><span class="dtx"><span class="dn">${esc(l.label ?? "Unlabelled")}</span><span class="dk">${esc(target?.title ?? l.to)}</span></span></div>`;
95
+ }).join("") : "";
96
+ const metric = sel.metrics;
97
+ const series = metric?.series ?? [];
98
+ const points = sparklinePoints(series, 300, 40);
99
+ const metricHtml = metric ? `<div class="insp-sec metrics"><b>Metrics</b><table class="meta"><tr><th>label</th><td>${esc(metric.label ?? "Metric")}</td></tr><tr><th>value</th><td>${esc(String(metric.value))} ${esc(metric.unit ?? "")}</td></tr>${metric.max != null ? `<tr><th>max</th><td>${esc(String(metric.max))} ${esc(metric.unit ?? "")}</td></tr>` : ""}</table>${points ? `<svg width="300" height="40" viewBox="0 0 300 40" role="img" aria-label="Metric trend"><polyline points="${points}"/></svg><div class="metric-range"><span>${esc(String(Math.min(...series)))}</span><span>${esc(String(Math.max(...series)))}</span></div>` : ""}</div>` : "";
85
100
 
86
101
  box.innerHTML = head + `<div class="counts">${counts}</div>
87
- <div class="insp-b"><div class="insp-sec"><p class="nname">${esc(sel.title)}</p><div class="nkind">${esc(model.columns[sel.col]?.title ?? "")} · ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
102
+ <div class="insp-b"><div class="insp-sec"><p class="nname">${esc(sel.title)}</p><div class="nkind">${esc(model.columns[sel.col ?? 0]?.title ?? "")} · ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
88
103
  <div class="insp-sec"><b>Details</b><table class="meta"><tr><th>state</th><td>${esc(st?.word ?? sid)}</td></tr><tr><th>members</th><td>${Math.max(1, sel.weight ?? 1)}</td></tr><tr><th>lane</th><td>${esc(lane?.title ?? sel.lane)}${lane?.badge ? ` (${esc(lane.badge)})` : ""}</td></tr>${sel.upgrade && bucket !== "good" ? `<tr><th>can improve</th><td>${esc(sel.upgrade)}</td></tr>` : ""}</table></div>
104
+ ${metricHtml}
105
+ ${sel.kind === "gateway" ? `<div class="insp-sec"><b>Exits</b><div class="drill">${exits}</div></div>` : ""}
89
106
  ${members.length ? `<div class="insp-sec"><b>Members</b> (${Math.max(members.length, sel.weight ?? 0)})<input id="mq" type="search" placeholder="Filter ${members.length} members…" aria-label="Filter members"><div class="drill" id="mlist"></div><p class="drill-hint" id="mcount"></p></div>` : `<div class="insp-sec"><p class="note">No member names are recorded for this group.</p></div>`}
107
+ ${foldAction ? `<div class="acts">${foldAction}</div>` : ""}
90
108
  <div class="acts"><button type="button" id="zPath">Zoom to path</button><button type="button" id="zBand">Go to lane</button></div>
91
109
  ${actions ? `<div class="acts">${actions}<button type="button" id="simReset"${ctx.simActive ? "" : " hidden"}>Reset</button></div>` : ""}</div>`;
92
110
  wire(box);
@@ -3,8 +3,9 @@
3
3
  import type { Ctx } from "./ctx.ts";
4
4
  import type { NodeDef, RelationDef } from "./model.ts";
5
5
  import type { Rect } from "./viewport.ts";
6
- import { connected, relatives, type Edge } from "./trace.ts";
7
- import { bucketOf, canonOf, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
6
+ import { connected, relatives, withinHops, type Edge } from "./trace.ts";
7
+ import { bucketOf, canonOf, effectiveLinks, nodeStateId, nodeVisible, toggleGroupState } from "./rules.ts";
8
+ import { notifySelection } from "./selection.ts";
8
9
 
9
10
  export function relationsOf(ctx: Ctx): RelationDef[] {
10
11
  const given = ctx.model.inspector?.relations;
@@ -18,7 +19,7 @@ export function relationsOf(ctx: Ctx): RelationDef[] {
18
19
  }
19
20
 
20
21
  export function visibleEdges(ctx: Ctx): Edge[] {
21
- return ctx.links.filter(l => linkVisible(ctx.model, ctx.mode, l, ctx.nodes)).map(l => ({ from: l.from, to: l.to }));
22
+ return effectiveLinks(ctx.model, ctx.mode, ctx.collapsed, ctx.nodes).map(l => ({ from: l.from, to: l.to }));
22
23
  }
23
24
 
24
25
  export function relationSet(ctx: Ctx, rel: RelationDef, start: string): Set<string> {
@@ -42,7 +43,7 @@ export function matches(ctx: Ctx, n: NodeDef): boolean {
42
43
  export function install(ctx: Ctx): void {
43
44
  const { els, model } = ctx;
44
45
  const canon = (id: string) => canonOf(ctx.nodes, id);
45
- const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n));
46
+ const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed));
46
47
  const inspInset = () => (els.inspector.classList.contains("open") ? 340 : 0);
47
48
 
48
49
  function activeSet(id: string | null): Set<string> | null {
@@ -60,7 +61,7 @@ export function install(ctx: Ctx): void {
60
61
  return ctx.filter === "gap" ? b === "gap" : !!n.upgrade && b !== "good";
61
62
  }).map(n => canon(n.id)));
62
63
  }
63
- if (ctx.query) return new Set(visibleNodes().filter(n => matches(ctx, n)).map(n => canon(n.id)));
64
+ if (ctx.query) return withinHops(edges, canon, visibleNodes().filter(n => matches(ctx, n)).map(n => n.id), ctx.hops);
64
65
  return null;
65
66
  }
66
67
 
@@ -84,6 +85,7 @@ export function install(ctx: Ctx): void {
84
85
 
85
86
  function select(id: string | null): void {
86
87
  clearSim();
88
+ const previous = ctx.selected;
87
89
  ctx.selected = id ? canon(id) : null;
88
90
  ctx.hl = null;
89
91
  ctx.dismissed = false;
@@ -92,6 +94,7 @@ export function install(ctx: Ctx): void {
92
94
  ctx.fn.renderInspector();
93
95
  drawTrail();
94
96
  if (ctx.selected) revealNode(ctx.selected);
97
+ notifySelection(previous, ctx.selected, ctx.onSelect);
95
98
  }
96
99
 
97
100
  /** An overlay is a copy of one link drawn on top. pathLength=1 makes the draw-in independent of zoom. */
@@ -180,7 +183,7 @@ export function install(ctx: Ctx): void {
180
183
  }
181
184
 
182
185
  function rectOf(ids: string[]): Rect | null {
183
- const ps = ids.map(i => ctx.geom.nodes[i]).filter((p): p is NonNullable<typeof p> => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id) as NodeDef));
186
+ const ps = ids.map(i => ctx.geom.nodes[i]).filter((p): p is NonNullable<typeof p> => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id) as NodeDef, ctx.collapsed));
184
187
  if (!ps.length) return null;
185
188
  const x1 = Math.min(...ps.map(p => p.x)), y1 = Math.min(...ps.map(p => p.y));
186
189
  const x2 = Math.max(...ps.map(p => p.x + p.w)), y2 = Math.max(...ps.map(p => p.y + p.h));
@@ -218,11 +221,11 @@ export function install(ctx: Ctx): void {
218
221
  clearSim();
219
222
  ctx.mode = id;
220
223
  const sel = ctx.selected && ctx.nodes.get(ctx.selected);
221
- if (sel && !nodeVisible(model, id, sel)) select(null);
224
+ if (sel && !nodeVisible(model, id, sel, ctx.collapsed)) select(null);
222
225
  ctx.fn.paint(true);
223
226
  ctx.userMoved = false;
224
227
  ctx.fn.fitWidth(true);
225
- const added = model.nodes.filter(n => nodeVisible(model, id, n) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n)).length;
228
+ const added = model.nodes.filter(n => nodeVisible(model, id, n, ctx.collapsed) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n, ctx.collapsed)).length;
226
229
  ctx.fn.announce(added ? `${model.views?.find(v => v.id === id)?.label ?? id} view: ${added} groups added.` : `${model.views?.find(v => v.id === id)?.label ?? id} view.`);
227
230
  }
228
231
 
@@ -236,5 +239,15 @@ export function install(ctx: Ctx): void {
236
239
  ctx.fn.zoomToBand = zoomToBand;
237
240
  ctx.fn.escape = escape;
238
241
  ctx.fn.setMode = setMode;
242
+ ctx.fn.toggleGroup = (id: string) => {
243
+ const group = model.nodes.find(n => n.id === id && n.kind === "group" && n.contains?.length);
244
+ if (!group) return;
245
+ const change = toggleGroupState(model, ctx.mode, ctx.collapsed, id);
246
+ if (!change) return;
247
+ ctx.collapsed = change.collapsed;
248
+ ctx.fn.paint();
249
+ ctx.fn.select(change.selected);
250
+ ctx.fn.announce(change.expanding ? `Expanded ${group.title}.` : `Collapsed into ${group.title}.`);
251
+ };
239
252
  ctx.fn.announce = (t: string) => { els.live.textContent = t; };
240
253
  }
@@ -67,3 +67,78 @@ test("a node whose column is out of range does not produce NaN", () => {
67
67
  const g = computeGeometry(mm);
68
68
  assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
69
69
  });
70
+ test("a gateway has a 64 px diamond box", () => {
71
+ const mm = m(); mm.nodes[0]!.kind = "gateway";
72
+ assert.equal(computeGeometry(mm).nodes.a!.h, 64);
73
+ });
74
+ test("loop-back channels stay below nodes, inside their lane, and separate from the next lane", () => {
75
+ const mm = m();
76
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
77
+ mm.links.push({ id: "again", from: "b", to: "a", state: "ok", kind: "loop-back" });
78
+ const g = computeGeometry(mm);
79
+ const y = g.loops.return!.channelY;
80
+ assert.ok(y >= g.nodes.a!.y + g.nodes.a!.h + 10);
81
+ assert.ok(y >= g.nodes.b!.y + g.nodes.b!.h + 10);
82
+ assert.equal(g.loops.again!.channelY, y + 8);
83
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h > g.loops.again!.channelY);
84
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
85
+ });
86
+
87
+ test("a loop clears a lower node within its horizontal span", () => {
88
+ const mm = m();
89
+ mm.columns.push({ id: "c2", title: "C", width: 150 });
90
+ mm.nodes.find(n => n.id === "b")!.col = 2;
91
+ mm.nodes.push({ id: "middle", kind: "card", lane: "l1", col: 1, row: 1, state: "ok", title: "Middle" });
92
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
93
+ const g = computeGeometry(mm);
94
+ assert.equal(g.loops.return!.channelY, g.nodes.middle!.y + g.nodes.middle!.h + 10);
95
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
96
+ });
97
+
98
+ test("a lower node outside the loop span does not lower its channel", () => {
99
+ const mm = m();
100
+ mm.columns.push({ id: "c2", title: "C", width: 150 });
101
+ mm.nodes.push({ id: "outside", kind: "card", lane: "l1", col: 2, row: 1, state: "ok", title: "Outside" });
102
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
103
+ const g = computeGeometry(mm);
104
+ assert.equal(g.loops.return!.channelY, g.nodes.a!.y + g.nodes.a!.h + 10);
105
+ });
106
+
107
+ test("interleaved lanes calculate channels after all endpoint shifts", () => {
108
+ const mm = m();
109
+ mm.nodes = [
110
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
111
+ { id: "deep", kind: "card", lane: "l1", col: 1, row: 5, state: "ok", title: "Deep" },
112
+ { id: "b", kind: "card", lane: "l2", col: 0, row: 1, state: "ok", title: "B" },
113
+ ];
114
+ mm.links = [{ id: "return", from: "deep", to: "b", state: "ok", kind: "loop-back" }];
115
+ const g = computeGeometry(mm);
116
+ assert.ok(g.loops.return!.channelY >= g.nodes.b!.y + g.nodes.b!.h + 10);
117
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
118
+ assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
119
+ assert.ok(Object.values(g.loops).every(l => Number.isFinite(l.channelY)));
120
+ });
121
+
122
+ test("free layout uses explicit world rectangles and works without columns", () => {
123
+ const mm = m();
124
+ mm.layout = "free"; mm.columns = [];
125
+ mm.nodes = [
126
+ { id: "a", kind: "card", lane: "l1", x: 18, y: 70, w: 121, h: 47, state: "ok", title: "A" },
127
+ { id: "b", kind: "card", lane: "l2", x: 250, y: 300, state: "ok", title: "B" },
128
+ ];
129
+ const g = computeGeometry(mm);
130
+ assert.deepEqual(g.xs, []);
131
+ assert.deepEqual(g.nodes.a, { id: "a", x: 18, y: 70, w: 121, h: 47 });
132
+ assert.equal(g.nodes.b?.w, 190);
133
+ assert.ok(g.world.w >= 440);
134
+ assert.ok(g.lanes.l1 && g.lanes.l2);
135
+ });
136
+
137
+ test("free layout preserves finite negative node coordinates", () => {
138
+ const mm = m(); mm.layout = "free"; mm.columns = [];
139
+ mm.nodes = [{ id: "a", kind: "card", lane: "l1", x: -80, y: -40, w: 60, h: 30, state: "ok", title: "A" }];
140
+ const g = computeGeometry(mm);
141
+ assert.equal(g.nodes.a!.x, -80);
142
+ assert.equal(g.nodes.a!.y, -40);
143
+ assert.ok([g.bounds.x, g.bounds.y, g.bounds.w, g.bounds.h, g.world.w, g.world.h].every(Number.isFinite));
144
+ });
@@ -1,7 +1,7 @@
1
1
  // Geometry from a model: where every node, lane and column sits in world units. Pure, no DOM.
2
2
  // Node sizes are fixed per kind, so layout never depends on fonts or text measurement.
3
3
 
4
- import type { Model, Kind } from "./model.ts";
4
+ import { NODE_HEIGHT, type Model } from "./model.ts";
5
5
  import type { Rect, Size } from "./viewport.ts";
6
6
 
7
7
  export interface Placed extends Rect { id: string }
@@ -9,6 +9,7 @@ export interface Geometry {
9
9
  xs: number[];
10
10
  nodes: Record<string, Placed>;
11
11
  lanes: Record<string, Rect>;
12
+ loops: Record<string, { channelY: number }>;
12
13
  bounds: Rect;
13
14
  world: Size;
14
15
  }
@@ -16,7 +17,6 @@ export interface LayoutOptions { pitch: number; top: number; gap: number; padX:
16
17
 
17
18
  export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
18
19
 
19
- const HEIGHT: Record<Kind, number> = { card: 54, group: 54, gateway: 54, placeholder: 54, chip: 34, start: 40, end: 40, event: 40, note: 40 };
20
20
  const LANE_PAD_TOP = 38; // room for the lane title
21
21
  const LANE_PAD_BOTTOM = 14;
22
22
 
@@ -29,21 +29,59 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
29
29
 
30
30
  const nodes: Record<string, Placed> = {};
31
31
  for (const n of m.nodes) {
32
- const h = HEIGHT[n.kind] ?? 54;
33
- const first = Math.min(Math.max(0, n.col), m.columns.length - 1);
32
+ const h = NODE_HEIGHT[n.kind] ?? 54;
33
+ if (m.layout === "free") {
34
+ nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
35
+ continue;
36
+ }
37
+ const first = Math.min(Math.max(0, n.col ?? 0), m.columns.length - 1);
34
38
  const last = Math.min(m.columns.length - 1, first + (n.colSpan ?? 1) - 1);
35
39
  const x0 = xs[first] ?? o.padX;
36
40
  const x1 = (xs[last] ?? x0) + (m.columns[last]?.width ?? 0);
37
- nodes[n.id] = { id: n.id, x: x0, y: o.top + 40 + n.row * o.pitch - h / 2, w: Math.max(1, x1 - x0), h };
41
+ nodes[n.id] = { id: n.id, x: x0, y: o.top + 40 + (n.row ?? 0) * o.pitch - h / 2, w: Math.max(1, x1 - x0), h };
38
42
  }
43
+ if (m.layout === "free") world.w = Math.max(world.w, ...Object.values(nodes).map(p => p.x + p.w + o.padX), 1);
39
44
 
45
+ const loops: Geometry["loops"] = {};
40
46
  const lanes: Record<string, Rect> = {};
41
- for (const l of m.lanes) {
42
- const mem = m.nodes.filter(n => n.lane === l.id && o.visible(n.id)).map(n => nodes[n.id]!);
43
- if (!mem.length) continue;
44
- const y1 = Math.min(...mem.map(p => p.y)) - LANE_PAD_TOP;
45
- const y2 = Math.max(...mem.map(p => p.y + p.h)) + LANE_PAD_BOTTOM;
47
+ const visibleMembers = (id: string) => m.nodes.filter(n => n.lane === id && o.visible(n.id)).map(n => nodes[n.id]!);
48
+ const ordered = m.lanes.map(l => ({ l, mem: visibleMembers(l.id) })).filter(x => x.mem.length)
49
+ .sort((a, b) => Math.min(...a.mem.map(p => p.y)) - Math.min(...b.mem.map(p => p.y)));
50
+ let previousBottom = -Infinity;
51
+ for (const { l, mem } of ordered) {
52
+ const top = Math.min(...mem.map(p => p.y)) - LANE_PAD_TOP;
53
+ const shift = m.layout === "free" ? 0 : Math.max(0, previousBottom + 8 - top);
54
+ if (shift) for (const p of mem) p.y += shift;
55
+ const y1 = top + shift;
56
+ const laneNodeIds = new Set(mem.map(p => p.id));
57
+ // Reserve channel space before shifting the following lane. The exact channel
58
+ // positions are calculated only after every lane has reached its final y.
59
+ const possibleLoops = m.links.filter(link => link.kind === "loop-back" && o.visible(link.from) && o.visible(link.to)
60
+ && (laneNodeIds.has(link.from) || laneNodeIds.has(link.to))).length;
61
+ const bottom = Math.max(...mem.map(p => p.y + p.h));
62
+ const y2 = bottom + (possibleLoops ? 10 + 8 * (possibleLoops - 1) : 0) + LANE_PAD_BOTTOM;
46
63
  lanes[l.id] = { x: 8, y: y1, w: world.w - 16, h: y2 - y1 };
64
+ previousBottom = y2;
65
+ }
66
+
67
+ const channelCounts = new Map<string, number>();
68
+ for (const link of m.links) {
69
+ if (link.kind !== "loop-back" || !o.visible(link.from) || !o.visible(link.to)) continue;
70
+ const a = nodes[link.from], b = nodes[link.to];
71
+ if (!a || !b) continue;
72
+ const lower = a.y + a.h >= b.y + b.h ? a : b;
73
+ const laneId = m.nodes.find(n => n.id === lower.id)?.lane;
74
+ if (!laneId || !lanes[laneId]) continue;
75
+ const left = Math.min(a.x + a.w / 2, b.x + b.w / 2);
76
+ const right = Math.max(a.x + a.w / 2, b.x + b.w / 2);
77
+ const laneMembers = visibleMembers(laneId);
78
+ const crossed = laneMembers.filter(p => p.x < right && p.x + p.w > left);
79
+ const lowestBottom = Math.max(a.y + a.h, b.y + b.h, ...crossed.map(p => p.y + p.h));
80
+ const index = channelCounts.get(laneId) ?? 0;
81
+ loops[link.id] = { channelY: lowestBottom + 10 + 8 * index };
82
+ channelCounts.set(laneId, index + 1);
83
+ const lane = lanes[laneId];
84
+ lane.h = Math.max(lane.h, loops[link.id]!.channelY + LANE_PAD_BOTTOM - lane.y);
47
85
  }
48
86
 
49
87
  const all = Object.values(lanes);
@@ -53,5 +91,5 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
53
91
  bounds = { x: 8, y: top, w: world.w - 16, h: Math.max(...all.map(r => r.y + r.h)) - top };
54
92
  }
55
93
  world.h = Math.max(1, bounds.y + bounds.h + 70);
56
- return { xs, nodes, lanes, bounds, world };
94
+ return { xs, nodes, lanes, loops, bounds, world };
57
95
  }
@@ -0,0 +1,20 @@
1
+ /** Replaced with the shared template at library build time. */
2
+ export const SHELL_BODY_HTML = "/*__SHELL_BODY__*/";
3
+ export { mount, type FlowStudio } from "./engine.ts";
4
+ export * from "./model.ts";
5
+ export { autoLayout } from "./autolayout.ts";
6
+ export { computeGeometry } from "./layout.ts";
7
+ export type { Geometry, LayoutOptions, Placed } from "./layout.ts";
8
+ export * from "./viewport.ts";
9
+ export * from "./routing.ts";
10
+ export * from "./trace.ts";
11
+ export * from "./rules.ts";
12
+ export * from "./playback.ts";
13
+ export * from "./diff.ts";
14
+ export * as viewport from "./viewport.ts";
15
+ export * as routing from "./routing.ts";
16
+ export * as trace from "./trace.ts";
17
+ export * as rules from "./rules.ts";
18
+ export * as playback from "./playback.ts";
19
+ export * as diff from "./diff.ts";
20
+ export { stateCss } from "./stateCss.ts";
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sparklinePoints, metricBadge, sparkline } from "./metrics.ts";
4
+
5
+ test("sparkline handles flat, missing and varied series", () => {
6
+ assert.equal(sparklinePoints([], 56, 12), "");
7
+ assert.equal(sparklinePoints([4, 4], 56, 12), "0,6 56,6");
8
+ assert.equal(sparklinePoints([0, 10], 56, 12), "0,12 56,0");
9
+ });
10
+
11
+ test("metricBadge renders value, unit, and consistent title", () => {
12
+ assert.equal(metricBadge(undefined), "");
13
+ const badge1 = metricBadge({ value: 92, unit: "ms", label: "Hit rate" });
14
+ assert.ok(badge1.includes('title="Hit rate: 92 ms"'), "Title includes label, value and unit");
15
+ assert.ok(badge1.includes(">92 ms"), "Badge body includes value and unit");
16
+ assert.ok(!badge1.includes("<i"), "No fill bar when max is omitted");
17
+ });
18
+
19
+ test("metricBadge renders clamped fill bar when max is valid, ignores invalid max", () => {
20
+ const withMax = metricBadge({ value: 50, max: 100, label: "CPU" });
21
+ assert.ok(withMax.includes('<i style="width:50%"></i>'));
22
+
23
+ const clamped = metricBadge({ value: 150, max: 100 });
24
+ assert.ok(clamped.includes('<i style="width:100%"></i>'));
25
+
26
+ const negativeVal = metricBadge({ value: -10, max: 100 });
27
+ assert.ok(negativeVal.includes('<i style="width:0%"></i>'));
28
+
29
+ const nullMax = metricBadge({ value: 50, max: null as any });
30
+ assert.ok(!nullMax.includes("<i"), "null max produces no bar");
31
+
32
+ const zeroMax = metricBadge({ value: 50, max: 0 });
33
+ assert.ok(!zeroMax.includes("<i"), "zero max produces no bar");
34
+ });
35
+
36
+ test("metricBadge escapes special HTML characters", () => {
37
+ const hostile = metricBadge({ value: 1, unit: '<script>"&', label: '<tag>&"' });
38
+ assert.ok(!hostile.includes("<script>"));
39
+ assert.ok(!hostile.includes("<tag>"));
40
+ assert.ok(hostile.includes("&lt;script&gt;"));
41
+ assert.ok(hostile.includes("&lt;tag&gt;"));
42
+ });
43
+ test("metricBadge escapes quotes so nothing can break out of the title attribute", () => {
44
+ // label and unit land inside title="…": a raw quote there would open attacker-controlled attributes.
45
+ const hostile = metricBadge({ value: 1, unit: '">&x', label: 'y" onmouseover="z' });
46
+ assert.ok(hostile.includes('title="y&quot; onmouseover=&quot;z: 1 &quot;&gt;&amp;x"'));
47
+ });
48
+
49
+ test("sparkline renders accessible SVG with aria-hidden", () => {
50
+ assert.equal(sparkline(undefined), "");
51
+ assert.equal(sparkline({ value: 10 }), "");
52
+ assert.equal(sparkline({ value: 10, series: [1] }), "");
53
+
54
+ const svg = sparkline({ value: 10, series: [2, 8] }, 56, 12);
55
+ assert.ok(svg.startsWith('<svg class="spark" width="56" height="12" viewBox="0 0 56 12" aria-hidden="true">'));
56
+ assert.ok(svg.includes('<polyline points="0,12 56,0"/>'));
57
+ });