@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
@@ -15,7 +15,7 @@
15
15
  <style>:root {
16
16
  --bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
17
17
  --accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
18
- --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035);
18
+ --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:1.1px; --edge-w-hot:1.5px; --edge-w-play:1.6px; --trail-w:1.8px; --ripple-w:2px;
19
19
  --warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
20
20
  --mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
21
21
  }
@@ -23,14 +23,14 @@
23
23
  :root:not([data-theme="light"]) {
24
24
  --bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
25
25
  --accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
26
- --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
26
+ --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04); --dot:rgba(255,255,255,.16);
27
27
  --warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
28
28
  }
29
29
  }
30
30
  :root[data-theme="dark"] {
31
31
  --bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
32
32
  --accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
33
- --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
33
+ --up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04); --dot:rgba(255,255,255,.16);
34
34
  --warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
35
35
  }
36
36
  * { box-sizing:border-box; }
@@ -72,7 +72,7 @@
72
72
  .workspace { display:block; height:max(540px, calc(100vh - 232px)); border:1px solid var(--edge); background:var(--panel); border-radius:12px; overflow:hidden; box-shadow:var(--shadow); }
73
73
  .canvaswrap { position:relative; display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
74
74
  #viewport { position:relative; flex:1; min-height:0; overflow:hidden; cursor:grab; touch-action:none; user-select:none;
75
- background:radial-gradient(760px 380px at 4% 0%, var(--glow), transparent 65%), radial-gradient(640px 320px at 98% 100%, color-mix(in srgb, var(--ok) 16%, transparent), transparent 70%), linear-gradient(var(--grid) 1px, transparent 1px) 0 0/28px 28px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0/28px 28px, var(--bg); }
75
+ background:radial-gradient(760px 380px at 4% 0%, var(--glow), transparent 65%), radial-gradient(640px 320px at 98% 100%, color-mix(in srgb, var(--ok) 16%, transparent), transparent 70%), radial-gradient(circle, var(--dot) var(--dot-size), transparent calc(var(--dot-size) + .5px)) 0 0 / var(--dot-gap) var(--dot-gap), var(--bg); }
76
76
  #viewport.panning { cursor:grabbing; } #viewport.hand .node { cursor:grab; }
77
77
  #world { position:absolute; left:0; top:0; transform-origin:0 0; will-change:transform; }
78
78
  #viewport.anim #world, #viewport.anim .hd, #viewport.anim .ph { transition:transform .28s cubic-bezier(.2,.8,.2,1), left .28s cubic-bezier(.2,.8,.2,1), width .28s cubic-bezier(.2,.8,.2,1); }
@@ -84,15 +84,21 @@
84
84
  .bandlabel b { font-size:13px; } .bandlabel .bd { font-size:10.5px; font-weight:700; padding:0 6px; border:1px solid currentColor; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
85
85
  .bandlabel .bd.t-ok { color:var(--ok); } .bandlabel .bd.t-warn { color:var(--warn); } .bandlabel .bn { font-size:11.5px; color:var(--muted); }
86
86
  .node { position:absolute; background:var(--panel); border:1px solid var(--edge); border-radius:8px; padding:5px 10px 7px; box-shadow:var(--shadow); transition:opacity .35s, transform .35s, box-shadow .2s, border-color .2s; cursor:pointer; z-index:2; }
87
+ .node.gateway { border:0; background:transparent; box-shadow:none; padding:0; border-radius:0; display:flex; align-items:center; justify-content:center; }
88
+ .node.gateway svg.gw { position:absolute; inset:0; width:100%; height:100%; overflow:visible; }
89
+ .node.gateway polygon { stroke-width:2; vector-effect:non-scaling-stroke; }
90
+ .node.gateway .gw-title { position:relative; width:58%; text-align:center; font-size:11px; font-weight:600; line-height:1.2; color:var(--fg); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow-wrap:anywhere; }
91
+ .node.gateway:hover, .node.gateway.lit, .node.gateway.sel { filter:drop-shadow(0 0 7px var(--accent)); }
87
92
  .node.wide { overflow:hidden; }
88
93
  .node:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--glow), var(--shadow); }
89
94
  .node .t { display:flex; gap:4px; align-items:flex-start; font-weight:600; line-height:1.25; font-size:13px; }
90
95
  .node .t .name { overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
96
+ .node .fold { flex:none; color:var(--accent); font-size:11px; }
91
97
  .node .s { color:var(--muted); font-size:12px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
92
98
  .ev { font-size:10.5px; font-weight:700; letter-spacing:.03em; }
93
99
  .node.wide .t { font-weight:500; } .node.wide .s { white-space:normal; }
94
- .node.ref { padding:4px 9px; border-style:dashed; box-shadow:none; background:color-mix(in srgb, var(--panel) 70%, transparent); } .node.ref .t { font-size:12px; } .node.ref .s { font-size:11px; }
95
- .node.sel { outline:2px solid var(--accent); outline-offset:2px; } .node.dim { opacity:.28; } .node.off { opacity:0; transform:translateY(8px); pointer-events:none; }
100
+ .node.ref { padding:4px 9px; border-style:dashed; box-shadow:none; background:color-mix(in srgb, var(--panel) 70%, transparent); } .node.ref .t { font-size:12px; } .node.ref .t .name { display:block; min-width:0; white-space:nowrap; text-overflow:ellipsis; } .node.ref .s { font-size:11px; }
101
+ .node.sel { outline:2px solid var(--accent); outline-offset:2px; } .node.dim { opacity:.28; } .node.off { opacity:0; visibility:hidden; transform:translateY(8px); pointer-events:none; }
96
102
  .node .bar { position:absolute; left:0; right:0; bottom:0; display:flex; height:4px; overflow:hidden; border-radius:0 0 7px 7px; background:var(--panel2); } .node .bar i { display:block; height:100%; } .bar .good { background:var(--ok); } .bar .neutral { background:var(--muted); } .bar .gap { background:var(--warn); }
97
103
  .newdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--accent); margin:5px 4px 0 0; flex:none; } body.alt .node.isnew .newdot { display:inline-block; }
98
104
  body.alt .node.isnew.pulse { animation:newring 1.8s ease-out 1; }
@@ -100,8 +106,8 @@
100
106
  .cor.up { right:6px; background:var(--info-bg); color:var(--accent); } .cor.meas { right:6px; background:var(--ok-bg); color:var(--ok); } .cor.up + .cor.meas { right:27px; }
101
107
  .node.hascor .t { padding-right:24px; } .node.hascor2 .t { padding-right:44px; }
102
108
  #viewport.lod1 .node .s, #viewport.lod1 .bandlabel .bn { display:none; } #viewport.lod2 .node .cor, #viewport.lod2 .node.ref .s { display:none; }
103
- path.edge { fill:none; stroke-width:1.8; transition:opacity .35s; vector-effect:non-scaling-stroke; }
104
- path.edge.hot { stroke:var(--accent); stroke-width:2.4; opacity:1; stroke-dasharray:6 8; animation:flowhot .9s linear infinite; filter:drop-shadow(0 0 5px var(--accent)); }
109
+ path.edge { fill:none; stroke-width:var(--edge-w); transition:opacity .35s; vector-effect:non-scaling-stroke; }
110
+ path.edge.hot { stroke:var(--accent); stroke-width:var(--edge-w-hot); opacity:1; stroke-dasharray:6 8; animation:flowhot .9s linear infinite; filter:drop-shadow(0 0 3px var(--accent)); }
105
111
  path.edge.dim { opacity:.07; filter:none; } path.edge.off { opacity:0; }
106
112
  .elabel { position:absolute; font-size:11px; padding:0 6px; border-radius:9px; background:var(--panel); border:1px solid var(--edge); color:var(--muted); white-space:nowrap; transform:translate(-50%,-50%); opacity:0; transition:opacity .35s; z-index:4; } .elabel.show { opacity:1; } .elabel.off { display:none; }
107
113
  #cols { position:absolute; left:0; right:0; top:0; height:44px; pointer-events:none; z-index:20; background:linear-gradient(var(--bg) 62%, transparent); }
@@ -154,8 +160,8 @@
154
160
  .node.lit { border-color:var(--accent); box-shadow:0 0 0 2px var(--glow), 0 0 18px var(--glow), var(--shadow); }
155
161
  .node.sel { animation:selpulse 2.4s ease-out infinite; }
156
162
  .node.fail { border-color:var(--crit); background:var(--crit-bg); box-shadow:0 0 0 2px var(--crit-bg), 0 0 20px color-mix(in srgb, var(--crit) 45%, transparent); animation:failflash .9s ease-out 1; } .node.fail .s { color:var(--crit); font-weight:600; }
157
- path.trail { fill:none; stroke:var(--accent); vector-effect:none; stroke-width:calc(3.4px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 6px var(--accent)); opacity:.95; }
158
- path.ripple { fill:none; stroke:var(--crit); vector-effect:none; stroke-width:calc(3.6px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 7px var(--crit)); }
163
+ path.trail { fill:none; stroke:var(--accent); vector-effect:none; stroke-width:calc(var(--trail-w) / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 4px var(--accent)); opacity:.95; }
164
+ path.ripple { fill:none; stroke:var(--crit); vector-effect:none; stroke-width:calc(var(--ripple-w) / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 4px var(--crit)); }
159
165
  .intro .band { animation:fadein .6s both; } .intro .node { animation:rise .55s both; animation-delay:calc(var(--c, 0) * 60ms + 150ms); } .intro #edges { animation:fadein 1s .5s both; }
160
166
  .intro .lcell, .intro .tile { animation:rise .5s both; animation-delay:calc(var(--i, 0) * 55ms); }
161
167
  #viewport:focus-visible { outline-offset:-2px; }
@@ -169,6 +175,34 @@
169
175
  @keyframes beat { 0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--ok) 60%, transparent);} 70%,100%{box-shadow:0 0 0 9px transparent;} }
170
176
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } path.edge.hot { stroke-dasharray:none; } }
171
177
  @media (max-width: 1100px) { .summary { grid-template-columns:1fr; } .workspace { height:auto; } .workspace .canvaswrap { height:75vh; } .insp { width:min(340px, 92%); } #drawer { width:100%; } .layerstrip { grid-template-columns:repeat(3,1fr) !important; } }
178
+ #banner:not([hidden]) { margin:0 14px 10px; padding:8px 12px; color:var(--crit); background:var(--crit-bg); border:1px solid var(--crit); font-size:12px; }
179
+ #playbar:not([hidden]) { display:flex; flex-wrap:wrap; align-items:center; gap:7px; padding:7px 12px; border-top:1px solid var(--rule); background:var(--panel); font-size:12px; }
180
+ #playbar select, #playbar button { font-size:11px; }
181
+ #pbScrub { flex:1 1 140px; min-width:90px; }
182
+ #pbCaption { flex:1 1 100%; color:var(--fg); }
183
+ #pbCounter { font:11px var(--mono); color:var(--fg); }
184
+ #pbCounter.warn { color:var(--warn); } #pbCounter.breach { color:var(--crit); font-weight:700; }
185
+ #pbChoices { display:flex; gap:5px; flex-wrap:wrap; } #pbChoices:empty { display:none; }
186
+ #pbChoices .suggested { border-color:var(--accent); box-shadow:inset 0 -2px 0 var(--accent); }
187
+ circle.token { fill:var(--accent); stroke:var(--panel); stroke-width:2; filter:drop-shadow(0 0 5px var(--accent)); pointer-events:none; }
188
+ path.edge.playing { stroke:var(--accent); stroke-width:var(--edge-w-play); filter:drop-shadow(0 0 4px var(--accent)); opacity:1; }
189
+ path.edge.visited { stroke:var(--accent); opacity:.7; }
190
+ .node.playing { outline:2px solid var(--accent); outline-offset:2px; }
191
+ /* The pulse runs only while the player really plays; a paused step keeps the plain outline. */
192
+ body.playing .node.playing { animation:selpulse 1.8s ease-out infinite; }
193
+ .node.visited::after { content:"✓"; position:absolute; right:3px; bottom:1px; color:var(--accent); font-size:11px; }
194
+ .node.updated { animation:newring 1.2s ease-out 1; }
195
+ .node.added { animation:rise 1.2s ease-out 1; }
196
+ .node .t .name { min-width:0; flex:1 1 auto; }
197
+ .node .metric-badge { flex:none; max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; position:relative; padding-bottom:3px; text-align:right; font:11px var(--mono); color:var(--accent); }
198
+ .node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
199
+ .node .s.has-spark { padding-right:60px; }
200
+ .node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
201
+ .node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
202
+ .node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
203
+ .node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
204
+ .metrics svg { max-width:100%; margin-top:8px; } .metric-range { display:flex; justify-content:space-between; width:300px; max-width:100%; font:10px var(--mono); color:var(--muted); }
205
+ @media (forced-colors: active) { #viewport { background: Canvas; } }
172
206
  </style>
173
207
  </head>
174
208
  <body>
@@ -181,23 +215,34 @@
181
215
  <main>
182
216
  <div class="titlerow"><h1 id="title"></h1><span class="readline" id="readat"></span></div>
183
217
  <p class="help" id="help"></p>
218
+ <div id="banner" role="alert" hidden></div>
184
219
 
185
220
  <section class="summary"><div class="layerstrip" id="layerstrip" aria-label="Completeness by column"></div><div class="tiles" id="kpis" aria-label="Summary"></div></section>
186
221
 
187
222
  <div class="toolbar">
188
223
  <div class="seggrp" role="tablist" aria-label="View" id="modeSwitch"></div>
189
224
  <input id="q" type="search" placeholder="Find a group or member… ( / )" aria-label="Find a group or member">
225
+ <select id="hops" aria-label="Search radius"><option value="0">This group</option><option value="1">+1 hop</option><option value="2">+2 hops</option><option value="3">+3 hops</option></select>
190
226
  <span id="matchinfo" class="note" style="margin:0"></span>
191
227
  </div>
192
228
 
193
229
  <div class="workspace" id="workspace">
194
230
  <section class="canvaswrap">
195
231
  <div id="viewport" tabindex="0" role="application" aria-label="Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.">
196
- <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="0"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
232
+ <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
197
233
  <div id="cols" aria-hidden="true"></div>
198
234
  <div id="tools" role="toolbar" aria-label="Canvas controls" aria-orientation="vertical"></div>
199
235
  <div id="zoomLabel" aria-hidden="true">100%</div>
200
236
  </div>
237
+ <div id="playbar" hidden aria-label="Scenario playback">
238
+ <select id="pbScenario" aria-label="Scenario"></select>
239
+ <button id="pbPlay" type="button" aria-label="Play">▶</button>
240
+ <button id="pbStep" type="button" aria-label="Step">Step</button>
241
+ <button id="pbReset" type="button" aria-label="Reset">Reset</button>
242
+ <select id="pbSpeed" aria-label="Speed"><option value="0.5">0.5x</option><option value="1" selected>1x</option><option value="2">2x</option></select>
243
+ <input id="pbScrub" type="range" min="0" max="1000" value="0" aria-label="Playback position" aria-valuetext="Step 1 of 1">
244
+ <span id="pbCaption"></span><span id="pbCounter"></span><span id="pbChoices"></span>
245
+ </div>
201
246
  <div class="keyline" id="legend" aria-label="Legend"></div>
202
247
  <aside class="insp" id="inspector" aria-label="Inspector" aria-hidden="true"></aside>
203
248
  </section>
@@ -211,11 +256,13 @@
211
256
  <footer id="footer"></footer>
212
257
  <aside id="drawer" aria-label="Implementation notes"><h2>Implementation notes</h2><p class="note">Numbered markers on the page match these cards. Hover a card to find its place; click a marker to jump to its card.</p><div id="notelist"></div></aside>
213
258
 
259
+
214
260
  <script type="application/json" id="flow-model">/*__MODEL__*/{}</script>
215
261
  <script type="application/json" id="flow-notes">/*__NOTES__*/[]</script>
216
262
  <script>"use strict";
217
263
  (() => {
218
264
  // src/model.ts
265
+ var NODE_HEIGHT = { card: 54, group: 54, gateway: 64, placeholder: 54, chip: 40, start: 40, end: 40, event: 40, note: 40 };
219
266
  var TONES = ["ok", "warn", "crit", "info", "muted", "accent"];
220
267
  var BORDERS = ["solid", "dashed", "dotted"];
221
268
  var EDGES = ["flow", "dash", "dash-short", "dot"];
@@ -234,9 +281,9 @@
234
281
  }
235
282
  const m = input;
236
283
  if (m.version !== 1) add("error", "model-version", "version", "version must be 1.");
237
- if (m.layout !== "columns-lanes") add("error", "layout-unsupported", "layout", `layout ${String(m.layout)} is not supported yet: use "columns-lanes" ("free" and "auto" arrive in a later stage).`);
238
- if (!Array.isArray(m.columns) || m.columns.length === 0) add("error", "columns-missing", "columns", "At least one column is required.");
239
- if (!Array.isArray(m.lanes) || m.lanes.length === 0) add("error", "lanes-missing", "lanes", "At least one lane is required.");
284
+ if (!["columns-lanes", "free", "auto"].includes(m.layout)) add("error", "layout-unsupported", "layout", `layout ${String(m.layout)} is not supported: use "columns-lanes", "free", or "auto".`);
285
+ if (!Array.isArray(m.columns) || m.layout === "columns-lanes" && m.columns.length === 0) add("error", "columns-missing", "columns", "Columns are required for columns-lanes layout.");
286
+ if (!Array.isArray(m.lanes) || m.layout !== "auto" && m.lanes.length === 0) add("error", "lanes-missing", "lanes", "At least one lane is required.");
240
287
  if (!m.states || typeof m.states !== "object" || Object.keys(m.states).length === 0) add("error", "states-missing", "states", "At least one state is required.");
241
288
  const columns = Array.isArray(m.columns) ? m.columns : [];
242
289
  const lanes = Array.isArray(m.lanes) ? m.lanes : [];
@@ -266,15 +313,36 @@
266
313
  const w = `nodes[${n.id}]`;
267
314
  if (nodeIds.has(n.id)) add("error", "node-duplicate", w, `Duplicate node id ${n.id}.`);
268
315
  nodeIds.add(n.id);
269
- if (!laneIds.has(n.lane)) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
316
+ if ((m.layout !== "auto" || n.lane !== void 0) && !laneIds.has(n.lane ?? "")) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
270
317
  for (const s of [n.state, ...Object.values(n.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
271
- if (typeof n.row !== "number" || !Number.isFinite(n.row)) add("error", "row-invalid", w, `Node ${n.id} needs a finite number for row (it sets the vertical position).`);
318
+ if (m.layout === "columns-lanes" && (typeof n.row !== "number" || !Number.isFinite(n.row))) add("error", "row-invalid", w, `Node ${n.id} needs a finite number for row (it sets the vertical position).`);
272
319
  const span = n.colSpan ?? 1;
273
- if (!Number.isInteger(n.col) || n.col < 0 || n.col + span > ncols) add("error", "column-range", w, `Column ${n.col} with span ${span} is outside 0..${ncols - 1}.`);
320
+ if (m.layout === "columns-lanes" && (!Number.isInteger(n.col) || (n.col ?? -1) < 0 || (n.col ?? 0) + span > ncols)) add("error", "column-range", w, `Column ${n.col} with span ${span} is outside 0..${ncols - 1}.`);
321
+ if (m.layout === "free") {
322
+ if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) add("error", "position-invalid", w, `Node ${n.id} needs finite x and y.`);
323
+ if (n.w !== void 0 && (!Number.isFinite(n.w) || n.w <= 0) || n.h !== void 0 && (!Number.isFinite(n.h) || n.h <= 0)) add("error", "size-invalid", w, `Node ${n.id} needs positive finite width and height.`);
324
+ }
325
+ const metric = n.metrics;
326
+ const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
327
+ if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
328
+ if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
274
329
  }
275
330
  for (const n of nodes) {
276
331
  if (n.kind === "chip" && (!n.ref || !nodeIds.has(n.ref))) add("error", "chip-ref", `nodes[${n.id}]`, `A chip must point at a real node (ref ${n.ref}).`);
277
332
  }
333
+ const owner = /* @__PURE__ */ new Map();
334
+ for (const group of nodes.filter((n) => n.contains !== void 0)) {
335
+ if (group.kind !== "group" || !Array.isArray(group.contains)) {
336
+ add("error", "contains-shape", `nodes[${group.id}]`, "Only a group may contain a list of node ids.");
337
+ continue;
338
+ }
339
+ for (const id of group.contains) {
340
+ if (id === group.id) add("error", "contains-self", `nodes[${group.id}]`, `Group ${group.id} cannot contain itself.`);
341
+ else if (!nodeIds.has(id)) add("error", "contains-undefined", `nodes[${group.id}]`, `Contained node ${id} does not exist.`);
342
+ else if (owner.has(id)) add("error", "contains-duplicate", `nodes[${group.id}]`, `Node ${id} is already contained by ${owner.get(id)}.`);
343
+ else owner.set(id, group.id);
344
+ }
345
+ }
278
346
  const linkIds = /* @__PURE__ */ new Set();
279
347
  const linked = /* @__PURE__ */ new Set();
280
348
  for (const l of links) {
@@ -286,23 +354,68 @@
286
354
  linked.add(l.from);
287
355
  linked.add(l.to);
288
356
  const a = nodes.find((n) => n.id === l.from), b = nodes.find((n) => n.id === l.to);
289
- if (b.col <= a.col + (a.colSpan ?? 1) - 1) {
290
- add("warn", "link-direction", w, `Link ${l.id} does not run left to right (column ${a.col} to ${b.col}); it will look wrong until loop-back links arrive.`);
357
+ if (m.layout !== "free" && a.col !== void 0 && b.col !== void 0) {
358
+ const forward = b.col > a.col + (a.colSpan ?? 1) - 1;
359
+ if (!forward && l.kind !== "loop-back") add("warn", "link-direction", w, `Link ${l.id} does not run left to right (column ${a.col} to ${b.col}); mark it loop-back if it returns to an earlier stage.`);
360
+ if (forward && l.kind === "loop-back") add("warn", "loop-back-forward", w, `Link ${l.id} is marked loop-back but runs forward.`);
291
361
  }
292
362
  }
293
363
  for (const s of [l.state, ...Object.values(l.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
294
364
  }
365
+ const scenarioIds = /* @__PURE__ */ new Set();
366
+ for (const [i, scenario] of (Array.isArray(m.scenarios) ? m.scenarios : []).entries()) {
367
+ const w = `scenarios[${i}]`;
368
+ if (!scenario || typeof scenario.id !== "string" || typeof scenario.label !== "string" || !Array.isArray(scenario.steps)) {
369
+ add("error", "scenario-shape", w, "A scenario needs an id, label and steps.");
370
+ continue;
371
+ }
372
+ if (scenarioIds.has(scenario.id)) add("error", "scenario-duplicate", w, `Duplicate scenario id ${scenario.id}.`);
373
+ scenarioIds.add(scenario.id);
374
+ if (!scenario.steps.length) add("error", "scenario-empty", w, "A scenario needs at least one step.");
375
+ const timed = scenario.steps.filter((s) => s && s.at !== void 0).length;
376
+ if (timed && timed !== scenario.steps.length) add("error", "scenario-at-partial", w, "Give every step an at time or none.");
377
+ let prev, previousAt = -Infinity;
378
+ for (const [j, step] of scenario.steps.entries()) {
379
+ const sw = `${w}.steps[${j}]`;
380
+ const link = links.find((l) => l.id === step?.link);
381
+ if (!link) add("error", "scenario-link", sw, `Unknown link ${step?.link}.`);
382
+ if (step?.at !== void 0) {
383
+ if (!Number.isFinite(step.at) || step.at < 0 || step.at < previousAt) add("error", "scenario-at-order", sw, "Step times must be finite, nonnegative and nondecreasing.");
384
+ if (Number.isFinite(step.at) && step.at >= 0 && step.at >= previousAt) previousAt = step.at;
385
+ }
386
+ if (step?.dur !== void 0 && (!Number.isFinite(step.dur) || step.dur <= 0)) add("error", "scenario-duration", sw, "Duration must be positive and finite.");
387
+ if (prev && link) {
388
+ const previousTo = prev.to;
389
+ if ((nodes.find((n) => n.id === previousTo)?.ref ?? previousTo) !== (nodes.find((n) => n.id === link.from)?.ref ?? link.from)) add("warn", "scenario-gap", sw, "This step does not start at the previous step's target.");
390
+ }
391
+ if (link) prev = link;
392
+ }
393
+ }
295
394
  for (const n of nodes) {
296
- if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note") {
395
+ if (n.kind !== "gateway") continue;
396
+ const exits = links.filter((l) => l.from === n.id);
397
+ if (exits.length === 0) add("warn", "gateway-no-exit", `nodes[${n.id}]`, `Gateway ${n.id} has no exit.`);
398
+ else if (exits.length > 1 && exits.some((l) => !l.label?.trim())) add("warn", "gateway-exit-label", `nodes[${n.id}]`, `Every exit from gateway ${n.id} needs a label.`);
399
+ }
400
+ for (const n of nodes) {
401
+ if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note" && !(n.kind === "group" && n.contains?.length)) {
297
402
  add("error", "orphan", `nodes[${n.id}]`, `Node ${n.id} has no links. Add a link, a placeholder neighbour, or set allowIsolated.`);
298
403
  }
299
404
  }
300
405
  for (let i = 0; i < nodes.length; i++) {
301
406
  for (let j = i + 1; j < nodes.length; j++) {
302
407
  const a = nodes[i], b = nodes[j];
303
- const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
304
- if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
305
- add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
408
+ if (a.contains?.includes(b.id) || b.contains?.includes(a.id)) continue;
409
+ if (m.layout === "free") {
410
+ const aw = a.w ?? 190, ah = a.h ?? NODE_HEIGHT[a.kind] ?? 54, bw = b.w ?? 190, bh = b.h ?? NODE_HEIGHT[b.kind] ?? 54;
411
+ if ([a.x, a.y, b.x, b.y, aw, ah, bw, bh].every((v) => typeof v === "number" && Number.isFinite(v)) && a.x < b.x + bw && b.x < a.x + aw && a.y < b.y + bh && b.y < a.y + ah) {
412
+ add("error", "rect-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap.`);
413
+ }
414
+ } else if (a.col !== void 0 && b.col !== void 0 && a.row !== void 0 && b.row !== void 0) {
415
+ const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
416
+ if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
417
+ add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
418
+ }
306
419
  }
307
420
  }
308
421
  }
@@ -311,6 +424,133 @@
311
424
  return out;
312
425
  }
313
426
 
427
+ // src/autolayout.ts
428
+ function autoLayout(input) {
429
+ const nodes = input.nodes.map((n) => ({ ...n }));
430
+ const links = input.links.map((l) => ({ ...l }));
431
+ const columns = input.columns.map((c) => ({ ...c }));
432
+ const lanes = input.lanes.map((l) => ({ ...l }));
433
+ const byId = new Map(nodes.map((n) => [n.id, n]));
434
+ const index = new Map(nodes.map((n, i) => [n.id, i]));
435
+ const outgoing = new Map(nodes.map((n) => [n.id, []]));
436
+ const incoming = new Map(nodes.map((n) => [n.id, []]));
437
+ const neighbours = new Map(nodes.map((n) => [n.id, []]));
438
+ for (const link of links) {
439
+ if (!byId.has(link.from) || !byId.has(link.to)) continue;
440
+ outgoing.get(link.from).push(link);
441
+ incoming.get(link.to).push(link);
442
+ neighbours.get(link.from).push(link.to);
443
+ neighbours.get(link.to).push(link.from);
444
+ }
445
+ const color = /* @__PURE__ */ new Map();
446
+ const post = [];
447
+ for (const root of nodes) {
448
+ if (color.has(root.id)) continue;
449
+ color.set(root.id, 1);
450
+ const stack = [{ id: root.id, next: 0 }];
451
+ while (stack.length) {
452
+ const frame = stack[stack.length - 1];
453
+ const edges = outgoing.get(frame.id);
454
+ if (frame.next >= edges.length) {
455
+ color.set(frame.id, 2);
456
+ post.push(frame.id);
457
+ stack.pop();
458
+ continue;
459
+ }
460
+ const link = edges[frame.next++];
461
+ if (link.kind === "loop-back") continue;
462
+ const state = color.get(link.to) ?? 0;
463
+ if (state === 1) link.kind = "loop-back";
464
+ else if (state === 0) {
465
+ color.set(link.to, 1);
466
+ stack.push({ id: link.to, next: 0 });
467
+ }
468
+ }
469
+ }
470
+ for (const id of post.reverse()) {
471
+ const n = byId.get(id);
472
+ if (n.col !== void 0) continue;
473
+ let col = 0;
474
+ for (const edge of incoming.get(id)) {
475
+ if (edge.kind === "loop-back") continue;
476
+ const parent = byId.get(edge.from);
477
+ col = Math.max(col, (parent.col ?? 0) + (parent.colSpan ?? 1));
478
+ }
479
+ n.col = col;
480
+ }
481
+ const seen = /* @__PURE__ */ new Set();
482
+ const usedLaneIds = new Set(lanes.map((l) => l.id));
483
+ let group = 0;
484
+ for (const first of nodes) {
485
+ if (seen.has(first.id)) continue;
486
+ const queue = [first.id];
487
+ seen.add(first.id);
488
+ for (let q = 0; q < queue.length; q++) {
489
+ for (const id of neighbours.get(queue[q])) if (!seen.has(id)) {
490
+ seen.add(id);
491
+ queue.push(id);
492
+ }
493
+ }
494
+ const missing = queue.map((id) => byId.get(id)).filter((n) => !n.lane);
495
+ if (!missing.length) continue;
496
+ group++;
497
+ let laneId = `group-${group}`;
498
+ while (usedLaneIds.has(laneId)) laneId += "-auto";
499
+ usedLaneIds.add(laneId);
500
+ lanes.push({ id: laneId, title: `Group ${group}` });
501
+ for (const n of missing) n.lane = laneId;
502
+ }
503
+ const rank = new Map(nodes.map((n, i) => [n.id, i]));
504
+ const laneOrder = lanes.map((l) => l.id);
505
+ const cells = /* @__PURE__ */ new Map();
506
+ for (const n of nodes) {
507
+ const key = `${n.lane}\0${n.col}`;
508
+ if (!cells.has(key)) cells.set(key, []);
509
+ cells.get(key).push(n);
510
+ }
511
+ const maxCol = Math.max(0, ...nodes.map((n) => (n.col ?? 0) + (n.colSpan ?? 1) - 1));
512
+ for (let sweep = 0; sweep < 4; sweep++) {
513
+ const forward = sweep % 2 === 0;
514
+ const layers = Array.from({ length: maxCol + 1 }, (_, i) => forward ? i : maxCol - i);
515
+ for (const lane of laneOrder) for (const col of layers) {
516
+ const groupNodes = cells.get(`${lane}\0${col}`);
517
+ if (!groupNodes || groupNodes.length < 2) continue;
518
+ const score = (n) => {
519
+ const edges = forward ? incoming.get(n.id) : outgoing.get(n.id);
520
+ const ns = edges.filter((e) => e.kind !== "loop-back").map((e) => forward ? e.from : e.to);
521
+ return ns.length ? ns.reduce((sum, id) => sum + (rank.get(id) ?? 0), 0) / ns.length : rank.get(n.id) ?? 0;
522
+ };
523
+ groupNodes.sort((a, b) => score(a) - score(b) || index.get(a.id) - index.get(b.id));
524
+ groupNodes.forEach((n, i) => rank.set(n.id, i));
525
+ }
526
+ }
527
+ let nextBase = 0;
528
+ const placed = nodes.filter((n) => n.row !== void 0);
529
+ for (const lane of laneOrder) {
530
+ const members = nodes.filter((n) => n.lane === lane);
531
+ if (!members.length) continue;
532
+ const ordered = [...members].sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || index.get(a.id) - index.get(b.id));
533
+ const rowAtCol = /* @__PURE__ */ new Map();
534
+ for (const n of ordered) {
535
+ if (n.row !== void 0) continue;
536
+ let row = Math.max(nextBase, rowAtCol.get(n.col ?? 0) ?? nextBase);
537
+ while (placed.some((p) => (n.col ?? 0) < (p.col ?? 0) + (p.colSpan ?? 1) && (p.col ?? 0) < (n.col ?? 0) + (n.colSpan ?? 1) && Math.abs(row - (p.row ?? 0)) < 1)) row += 1;
538
+ n.row = row;
539
+ placed.push(n);
540
+ rowAtCol.set(n.col ?? 0, row + 1);
541
+ }
542
+ const last = Math.max(nextBase - 1, ...members.map((n) => n.row ?? 0));
543
+ const loops = links.filter((l) => l.kind === "loop-back" && byId.get(l.from)?.lane === lane && byId.get(l.to)?.lane === lane).length;
544
+ nextBase = last + 1.75 + loops * 0.35;
545
+ }
546
+ const needed = Math.max(0, ...nodes.map((n) => (n.col ?? 0) + (n.colSpan ?? 1)));
547
+ while (columns.length < needed) {
548
+ const i = columns.length + 1;
549
+ columns.push({ id: `stage-${i}`, title: `Stage ${i}`, width: 190 });
550
+ }
551
+ return { ...input, nodes, links, columns, lanes };
552
+ }
553
+
314
554
  // src/stateCss.ts
315
555
  var cssId = (id) => id.replace(/[^A-Za-z0-9_-]/g, "_");
316
556
  var TONES2 = { ok: "var(--ok)", warn: "var(--warn)", crit: "var(--crit)", info: "var(--up)", muted: "var(--muted)", accent: "var(--accent)" };
@@ -330,6 +570,7 @@
330
570
  const glow = s.bucket === "good" ? ` box-shadow:0 0 14px -4px color-mix(in srgb, ${T} 55%, transparent), var(--shadow);` : "";
331
571
  out.push(`.node.st-${c} { border-left:4px ${s.border} ${T};${glow} --breath:${T}; }`);
332
572
  }
573
+ out.push(`.node.st-${c}.gateway polygon { stroke:${T}; fill:color-mix(in srgb, ${T} 10%, var(--panel)); }`);
333
574
  out.push(`.ev.st-${c} { color:${T}; }`);
334
575
  if (s.motion === "breathe") out.push(`.node.st-${c}:not(.dim) { animation:breathe 3.2s ease-in-out infinite; }`);
335
576
  const edge = Object.hasOwn(DASH, s.edge) ? s.edge : "dash";
@@ -349,14 +590,32 @@
349
590
  // src/rules.ts
350
591
  var baselineView = (m) => m.views?.[0]?.id ?? "all";
351
592
  var defaultView = (m) => (m.views?.find((v) => v.default) ?? m.views?.[0])?.id ?? "all";
352
- var nodeVisible = (_m, view, n) => !n.visibleIn || n.visibleIn.includes(view);
353
- var nodeStateId = (_m, view, n) => n.stateIn?.[view] ?? n.state;
354
- function linkVisible(m, view, l, nodes) {
355
- if (l.visibleIn && !l.visibleIn.includes(view)) return false;
356
- const a = nodes.get(l.from);
357
- const b = nodes.get(l.to);
358
- return !!a && !!b && nodeVisible(m, view, a) && nodeVisible(m, view, b);
593
+ var initialCollapsed = (m) => new Set(m.nodes.filter((n) => n.kind === "group" && n.contains && n.collapsed !== false).map((n) => n.id));
594
+ var columnTitle = (m, n) => n.col === void 0 ? "" : m.columns[n.col]?.title ?? "";
595
+ function groupFor(m, nodeId) {
596
+ return m.nodes.find((n) => n.kind === "group" && n.contains?.includes(nodeId));
597
+ }
598
+ var nodeVisible = (m, view, n, collapsed) => {
599
+ if (n.visibleIn && !n.visibleIn.includes(view)) return false;
600
+ if (!collapsed) return true;
601
+ if (n.kind === "group" && n.contains) return collapsed.has(n.id);
602
+ const owner = groupFor(m, n.id);
603
+ return !owner || !collapsed.has(owner.id);
604
+ };
605
+ function toggleGroupState(m, view, collapsed, id) {
606
+ const group = m.nodes.find((n) => n.id === id && n.kind === "group" && n.contains?.length);
607
+ if (!group) return null;
608
+ const next = new Set(collapsed);
609
+ const expanding = next.has(id);
610
+ if (expanding) next.delete(id);
611
+ else next.add(id);
612
+ const selected = expanding ? group.contains?.find((child) => {
613
+ const n = m.nodes.find((candidate) => candidate.id === child);
614
+ return !!n && nodeVisible(m, view, n, next);
615
+ }) ?? null : id;
616
+ return { collapsed: next, selected, expanding };
359
617
  }
618
+ var nodeStateId = (_m, view, n) => n.stateIn?.[view] ?? n.state;
360
619
  var linkStateId = (_m, view, l) => l.stateIn?.[view] ?? l.state;
361
620
  var bucketOf = (m, stateId) => m.states[stateId]?.bucket ?? "gap";
362
621
  var isNew = (m, view, n) => view !== baselineView(m) && nodeVisible(m, view, n) && !nodeVisible(m, baselineView(m), n);
@@ -365,6 +624,27 @@
365
624
  return view !== base && nodeVisible(m, base, n) && bucketOf(m, nodeStateId(m, base, n)) === "gap" && bucketOf(m, nodeStateId(m, view, n)) !== "gap";
366
625
  };
367
626
  var canonOf = (nodes, id) => nodes.get(id)?.ref ?? id;
627
+ function effectiveLinks(m, view, collapsed, nodes) {
628
+ const byPair = /* @__PURE__ */ new Map();
629
+ const rank = { good: 0, neutral: 1, gap: 2 };
630
+ for (const link of m.links) {
631
+ if (link.visibleIn && !link.visibleIn.includes(view)) continue;
632
+ const a = nodes.get(link.from), b = nodes.get(link.to);
633
+ if (!a || !b || a.visibleIn && !a.visibleIn.includes(view) || b.visibleIn && !b.visibleIn.includes(view)) continue;
634
+ const groupA = groupFor(m, a.id), groupB = groupFor(m, b.id);
635
+ const from = groupA && collapsed.has(groupA.id) ? groupA.id : a.id;
636
+ const to = groupB && collapsed.has(groupB.id) ? groupB.id : b.id;
637
+ if (from === to) continue;
638
+ const fromNode = nodes.get(from), toNode = nodes.get(to);
639
+ if (!fromNode || !toNode || !nodeVisible(m, view, fromNode, collapsed) || !nodeVisible(m, view, toNode, collapsed)) continue;
640
+ const state = linkStateId(m, view, link);
641
+ const key = JSON.stringify([from, to]);
642
+ const prev = byPair.get(key);
643
+ if (!prev) byPair.set(key, { ...link, from, to, state, stateIn: void 0 });
644
+ else if (rank[bucketOf(m, state)] > rank[bucketOf(m, prev.state)]) prev.state = state;
645
+ }
646
+ return [...byPair.values()];
647
+ }
368
648
 
369
649
  // src/trace.ts
370
650
  function relatives(edges, canonOf2, start, dir) {
@@ -390,6 +670,33 @@
390
670
  function connected(edges, canonOf2, start) {
391
671
  return /* @__PURE__ */ new Set([...relatives(edges, canonOf2, start, "up"), ...relatives(edges, canonOf2, start, "down")]);
392
672
  }
673
+ function withinHops(edges, canonOf2, starts, n) {
674
+ const found = new Set(starts.map(canonOf2));
675
+ const radius = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
676
+ let frontier = [...found];
677
+ for (let hop = 0; hop < radius && frontier.length; hop++) {
678
+ const next = [];
679
+ const current = new Set(frontier);
680
+ for (const edge of edges) {
681
+ const a = canonOf2(edge.from), b = canonOf2(edge.to);
682
+ if (current.has(a) && !found.has(b)) {
683
+ found.add(b);
684
+ next.push(b);
685
+ }
686
+ if (current.has(b) && !found.has(a)) {
687
+ found.add(a);
688
+ next.push(a);
689
+ }
690
+ }
691
+ frontier = next;
692
+ }
693
+ return found;
694
+ }
695
+
696
+ // src/selection.ts
697
+ function notifySelection(previous, next, onSelect) {
698
+ if (previous !== next) onSelect?.(next);
699
+ }
393
700
 
394
701
  // src/interact.ts
395
702
  function relationsOf(ctx) {
@@ -403,7 +710,7 @@
403
710
  ];
404
711
  }
405
712
  function visibleEdges(ctx) {
406
- return ctx.links.filter((l) => linkVisible(ctx.model, ctx.mode, l, ctx.nodes)).map((l) => ({ from: l.from, to: l.to }));
713
+ return effectiveLinks(ctx.model, ctx.mode, ctx.collapsed, ctx.nodes).map((l) => ({ from: l.from, to: l.to }));
407
714
  }
408
715
  function relationSet(ctx, rel, start) {
409
716
  const canon = (id) => canonOf(ctx.nodes, id);
@@ -424,7 +731,7 @@
424
731
  function install(ctx) {
425
732
  const { els, model } = ctx;
426
733
  const canon = (id) => canonOf(ctx.nodes, id);
427
- const visibleNodes = () => model.nodes.filter((n) => nodeVisible(model, ctx.mode, n));
734
+ const visibleNodes = () => model.nodes.filter((n) => nodeVisible(model, ctx.mode, n, ctx.collapsed));
428
735
  const inspInset = () => els.inspector.classList.contains("open") ? 340 : 0;
429
736
  function activeSet(id) {
430
737
  const edges = visibleEdges(ctx);
@@ -441,7 +748,7 @@
441
748
  return ctx.filter === "gap" ? b === "gap" : !!n.upgrade && b !== "good";
442
749
  }).map((n) => canon(n.id)));
443
750
  }
444
- if (ctx.query) return new Set(visibleNodes().filter((n) => matches(ctx, n)).map((n) => canon(n.id)));
751
+ if (ctx.query) return withinHops(edges, canon, visibleNodes().filter((n) => matches(ctx, n)).map((n) => n.id), ctx.hops);
445
752
  return null;
446
753
  }
447
754
  function trace(id) {
@@ -465,6 +772,7 @@
465
772
  }
466
773
  function select(id) {
467
774
  clearSim();
775
+ const previous = ctx.selected;
468
776
  ctx.selected = id ? canon(id) : null;
469
777
  ctx.hl = null;
470
778
  ctx.dismissed = false;
@@ -473,6 +781,7 @@
473
781
  ctx.fn.renderInspector();
474
782
  drawTrail();
475
783
  if (ctx.selected) revealNode(ctx.selected);
784
+ notifySelection(previous, ctx.selected, ctx.onSelect);
476
785
  }
477
786
  function overlay(edge, cls, delay, dur) {
478
787
  const o = edge.cloneNode(false);
@@ -565,7 +874,7 @@
565
874
  }
566
875
  }
567
876
  function rectOf(ids) {
568
- const ps = ids.map((i) => ctx.geom.nodes[i]).filter((p) => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id)));
877
+ const ps = ids.map((i) => ctx.geom.nodes[i]).filter((p) => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id), ctx.collapsed));
569
878
  if (!ps.length) return null;
570
879
  const x1 = Math.min(...ps.map((p) => p.x)), y1 = Math.min(...ps.map((p) => p.y));
571
880
  const x2 = Math.max(...ps.map((p) => p.x + p.w)), y2 = Math.max(...ps.map((p) => p.y + p.h));
@@ -606,11 +915,11 @@
606
915
  clearSim();
607
916
  ctx.mode = id;
608
917
  const sel = ctx.selected && ctx.nodes.get(ctx.selected);
609
- if (sel && !nodeVisible(model, id, sel)) select(null);
918
+ if (sel && !nodeVisible(model, id, sel, ctx.collapsed)) select(null);
610
919
  ctx.fn.paint(true);
611
920
  ctx.userMoved = false;
612
921
  ctx.fn.fitWidth(true);
613
- const added = model.nodes.filter((n) => nodeVisible(model, id, n) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n)).length;
922
+ const added = model.nodes.filter((n) => nodeVisible(model, id, n, ctx.collapsed) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n, ctx.collapsed)).length;
614
923
  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.`);
615
924
  }
616
925
  ctx.fn.trace = trace;
@@ -623,6 +932,16 @@
623
932
  ctx.fn.zoomToBand = zoomToBand;
624
933
  ctx.fn.escape = escape;
625
934
  ctx.fn.setMode = setMode;
935
+ ctx.fn.toggleGroup = (id) => {
936
+ const group = model.nodes.find((n) => n.id === id && n.kind === "group" && n.contains?.length);
937
+ if (!group) return;
938
+ const change = toggleGroupState(model, ctx.mode, ctx.collapsed, id);
939
+ if (!change) return;
940
+ ctx.collapsed = change.collapsed;
941
+ ctx.fn.paint();
942
+ ctx.fn.select(change.selected);
943
+ ctx.fn.announce(change.expanding ? `Expanded ${group.title}.` : `Collapsed into ${group.title}.`);
944
+ };
626
945
  ctx.fn.announce = (t) => {
627
946
  els.live.textContent = t;
628
947
  };
@@ -648,7 +967,6 @@
648
967
 
649
968
  // src/layout.ts
650
969
  var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
651
- var HEIGHT = { card: 54, group: 54, gateway: 54, placeholder: 54, chip: 34, start: 40, end: 40, event: 40, note: 40 };
652
970
  var LANE_PAD_TOP = 38;
653
971
  var LANE_PAD_BOTTOM = 14;
654
972
  function computeGeometry(m, opt = {}) {
@@ -662,20 +980,53 @@
662
980
  const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
663
981
  const nodes = {};
664
982
  for (const n of m.nodes) {
665
- const h = HEIGHT[n.kind] ?? 54;
666
- const first = Math.min(Math.max(0, n.col), m.columns.length - 1);
983
+ const h = NODE_HEIGHT[n.kind] ?? 54;
984
+ if (m.layout === "free") {
985
+ nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
986
+ continue;
987
+ }
988
+ const first = Math.min(Math.max(0, n.col ?? 0), m.columns.length - 1);
667
989
  const last = Math.min(m.columns.length - 1, first + (n.colSpan ?? 1) - 1);
668
990
  const x0 = xs[first] ?? o.padX;
669
991
  const x1 = (xs[last] ?? x0) + (m.columns[last]?.width ?? 0);
670
- 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 };
992
+ 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 };
671
993
  }
994
+ if (m.layout === "free") world.w = Math.max(world.w, ...Object.values(nodes).map((p) => p.x + p.w + o.padX), 1);
995
+ const loops = {};
672
996
  const lanes = {};
673
- for (const l of m.lanes) {
674
- const mem = m.nodes.filter((n) => n.lane === l.id && o.visible(n.id)).map((n) => nodes[n.id]);
675
- if (!mem.length) continue;
676
- const y1 = Math.min(...mem.map((p) => p.y)) - LANE_PAD_TOP;
677
- const y2 = Math.max(...mem.map((p) => p.y + p.h)) + LANE_PAD_BOTTOM;
997
+ const visibleMembers = (id) => m.nodes.filter((n) => n.lane === id && o.visible(n.id)).map((n) => nodes[n.id]);
998
+ const ordered = m.lanes.map((l) => ({ l, mem: visibleMembers(l.id) })).filter((x2) => x2.mem.length).sort((a, b) => Math.min(...a.mem.map((p) => p.y)) - Math.min(...b.mem.map((p) => p.y)));
999
+ let previousBottom = -Infinity;
1000
+ for (const { l, mem } of ordered) {
1001
+ const top = Math.min(...mem.map((p) => p.y)) - LANE_PAD_TOP;
1002
+ const shift = m.layout === "free" ? 0 : Math.max(0, previousBottom + 8 - top);
1003
+ if (shift) for (const p of mem) p.y += shift;
1004
+ const y1 = top + shift;
1005
+ const laneNodeIds = new Set(mem.map((p) => p.id));
1006
+ const possibleLoops = m.links.filter((link) => link.kind === "loop-back" && o.visible(link.from) && o.visible(link.to) && (laneNodeIds.has(link.from) || laneNodeIds.has(link.to))).length;
1007
+ const bottom = Math.max(...mem.map((p) => p.y + p.h));
1008
+ const y2 = bottom + (possibleLoops ? 10 + 8 * (possibleLoops - 1) : 0) + LANE_PAD_BOTTOM;
678
1009
  lanes[l.id] = { x: 8, y: y1, w: world.w - 16, h: y2 - y1 };
1010
+ previousBottom = y2;
1011
+ }
1012
+ const channelCounts = /* @__PURE__ */ new Map();
1013
+ for (const link of m.links) {
1014
+ if (link.kind !== "loop-back" || !o.visible(link.from) || !o.visible(link.to)) continue;
1015
+ const a = nodes[link.from], b = nodes[link.to];
1016
+ if (!a || !b) continue;
1017
+ const lower = a.y + a.h >= b.y + b.h ? a : b;
1018
+ const laneId = m.nodes.find((n) => n.id === lower.id)?.lane;
1019
+ if (!laneId || !lanes[laneId]) continue;
1020
+ const left = Math.min(a.x + a.w / 2, b.x + b.w / 2);
1021
+ const right = Math.max(a.x + a.w / 2, b.x + b.w / 2);
1022
+ const laneMembers = visibleMembers(laneId);
1023
+ const crossed = laneMembers.filter((p) => p.x < right && p.x + p.w > left);
1024
+ const lowestBottom = Math.max(a.y + a.h, b.y + b.h, ...crossed.map((p) => p.y + p.h));
1025
+ const index = channelCounts.get(laneId) ?? 0;
1026
+ loops[link.id] = { channelY: lowestBottom + 10 + 8 * index };
1027
+ channelCounts.set(laneId, index + 1);
1028
+ const lane = lanes[laneId];
1029
+ lane.h = Math.max(lane.h, loops[link.id].channelY + LANE_PAD_BOTTOM - lane.y);
679
1030
  }
680
1031
  const all = Object.values(lanes);
681
1032
  let bounds = { x: 0, y: 0, w: world.w, h: 1 };
@@ -684,7 +1035,7 @@
684
1035
  bounds = { x: 8, y: top, w: world.w - 16, h: Math.max(...all.map((r) => r.y + r.h)) - top };
685
1036
  }
686
1037
  world.h = Math.max(1, bounds.y + bounds.h + 70);
687
- return { xs, nodes, lanes, bounds, world };
1038
+ return { xs, nodes, lanes, loops, bounds, world };
688
1039
  }
689
1040
 
690
1041
  // src/routing.ts
@@ -695,14 +1046,85 @@
695
1046
  const dir = y2 > y1 ? 1 : -1;
696
1047
  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}`;
697
1048
  }
1049
+ function loopBack(x1, y1, x2, y2, channelY, detour = {}) {
1050
+ x1 = Number.isFinite(x1) ? x1 : 0;
1051
+ x2 = Number.isFinite(x2) ? x2 : 0;
1052
+ y1 = Number.isFinite(y1) ? y1 : 0;
1053
+ y2 = Number.isFinite(y2) ? y2 : 0;
1054
+ channelY = Number.isFinite(channelY) ? channelY : Math.max(y1, y2) + 10;
1055
+ channelY = Math.max(channelY, y1, y2);
1056
+ const from = detour.from, to = detour.to;
1057
+ const startX = from?.x ?? x1, endX = to?.x ?? x2;
1058
+ const start = from ? `M${x1},${y1} V${from.y} H${startX}` : `M${x1},${y1}`;
1059
+ const finish = to ? ` V${to.y} H${x2} V${y2}` : ` V${y2}`;
1060
+ if (from || to) {
1061
+ const dir2 = endX >= startX ? 1 : -1;
1062
+ const r2 = Math.max(0, Math.min(8, Math.abs(endX - startX) / 2, (channelY - (from?.y ?? y1)) / 2, (channelY - (to?.y ?? y2)) / 2));
1063
+ return `${start} V${channelY - r2} Q${startX},${channelY} ${startX + dir2 * r2},${channelY} H${endX - dir2 * r2} Q${endX},${channelY} ${endX},${channelY - r2}${finish}`;
1064
+ }
1065
+ const dx = x2 - x1;
1066
+ const dir = dx >= 0 ? 1 : -1;
1067
+ const run = Math.abs(dx) || 32;
1068
+ const radius = Math.min(8, run / 2, (channelY - y1) / 2, (channelY - y2) / 2);
1069
+ const r = Math.max(0, radius);
1070
+ if (dx === 0) {
1071
+ 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}`;
1072
+ }
1073
+ 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}`;
1074
+ }
1075
+
1076
+ // src/metrics.ts
1077
+ function sparklinePoints(series, width, height) {
1078
+ if (!Array.isArray(series) || series.length < 2 || series.some((v) => !Number.isFinite(v))) return "";
1079
+ const min = Math.min(...series), max = Math.max(...series), span = max - min;
1080
+ return series.map((v, i) => `${Math.round(i / (series.length - 1) * width * 100) / 100},${Math.round((span ? 1 - (v - min) / span : 0.5) * height * 100) / 100}`).join(" ");
1081
+ }
1082
+ function metricBadge(metric) {
1083
+ if (!metric) return "";
1084
+ const label = metric.label ?? "Metric";
1085
+ const valStr = esc(String(metric.value));
1086
+ const unitStr = metric.unit ? " " + esc(metric.unit) : "";
1087
+ const title = `${esc(label)}: ${valStr}${unitStr}`;
1088
+ const hasMax = metric.max != null && Number.isFinite(metric.max) && metric.max > 0;
1089
+ const pct = hasMax && Number.isFinite(metric.value) ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0;
1090
+ const bar = hasMax ? `<i style="width:${pct}%"></i>` : "";
1091
+ return `<span class="metric-badge" title="${title}">${valStr}${unitStr}${bar}</span>`;
1092
+ }
1093
+ function sparkline(metric, width = 56, height = 12) {
1094
+ if (!metric?.series || metric.series.length < 2) return "";
1095
+ const points = sparklinePoints(metric.series, width, height);
1096
+ if (!points) return "";
1097
+ return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
1098
+ }
698
1099
 
699
1100
  // src/render.ts
1101
+ function loopRoute(ctx, fromId, toId, from, to, channel) {
1102
+ const detourFor = (end, other) => {
1103
+ const center = end.x + end.w / 2, bottom = end.y + end.h;
1104
+ const blocker = ctx.model.nodes.filter((n) => n.id !== fromId && n.id !== toId && nodeVisible(ctx.model, ctx.mode, n, ctx.collapsed)).map((n) => ctx.geom.nodes[n.id]).filter((p) => !!p && p.x < center && center < p.x + p.w && p.y > bottom && p.y < channel).sort((a, b) => a.y - b.y)[0];
1105
+ if (!blocker) return void 0;
1106
+ return { x: other.x + other.w / 2 < center ? blocker.x - 10 : blocker.x + blocker.w + 10, y: (bottom + blocker.y) / 2 };
1107
+ };
1108
+ return loopBack(
1109
+ from.x + from.w / 2,
1110
+ from.y + from.h,
1111
+ to.x + to.w / 2,
1112
+ to.y + to.h,
1113
+ channel,
1114
+ { from: detourFor(from, to), to: detourFor(to, from) }
1115
+ );
1116
+ }
700
1117
  function nodeHtml(ctx, n) {
1118
+ const badge = metricBadge(n.metrics);
1119
+ const spark = sparkline(n.metrics, 56, 12);
1120
+ if (n.kind === "gateway") {
1121
+ 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}`;
1122
+ }
701
1123
  const m = ctx.model;
702
1124
  const sid = nodeStateId(m, ctx.mode, n);
703
1125
  const word = m.states[sid]?.word ?? sid;
704
1126
  const bucket = bucketOf(m, sid);
705
- const sub = n.subtitle ?? m.columns[n.col]?.title ?? "";
1127
+ const sub = n.subtitle ?? m.columns[n.col ?? 0]?.title ?? "";
706
1128
  const weight = Math.max(1, n.weight ?? 1);
707
1129
  const showWord = n.kind !== "placeholder" && n.kind !== "note";
708
1130
  const bar = weight > 1 && n.kind !== "chip" ? `<div class="bar"><i class="${["good", "neutral", "gap"].includes(bucket) ? bucket : "gap"}" style="width:100%"></i></div>` : "";
@@ -710,7 +1132,8 @@
710
1132
  const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">\u2197</span>` : "";
711
1133
  const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
712
1134
  const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
713
- return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span></div><div class="s" title="${esc(sub)}">${lead}${esc(sub)}</div>${bar}${improved}${upgrade}`;
1135
+ const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
1136
+ return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
714
1137
  }
715
1138
  function build(ctx) {
716
1139
  const { model, els } = ctx;
@@ -749,7 +1172,7 @@
749
1172
  d.id = "n_" + n.id;
750
1173
  d.tabIndex = 0;
751
1174
  d.setAttribute("role", "button");
752
- d.style.setProperty("--c", String(n.col));
1175
+ d.style.setProperty("--c", String(n.col ?? 0));
753
1176
  d.style.left = p.x + "px";
754
1177
  d.style.top = p.y + "px";
755
1178
  d.style.width = p.w + "px";
@@ -784,7 +1207,10 @@
784
1207
  return;
785
1208
  }
786
1209
  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;
787
- const path = sv("path", { d: elbow(x1, y1, x2 - 1, y2, tx), class: "edge", "data-i": String(i), "data-a": l.from, "data-b": l.to, "marker-end": "url(#arr)" });
1210
+ const loop = l.kind === "loop-back";
1211
+ const channel = ctx.geom.loops[l.id]?.channelY ?? Math.max(A.y + A.h, B.y + B.h) + 10;
1212
+ const d = loop ? loopRoute(ctx, l.from, l.to, A, B, channel) : elbow(x1, y1, x2 - 1, y2, tx);
1213
+ 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)" });
788
1214
  els.layer.appendChild(path);
789
1215
  ctx.edgeEls[i] = path;
790
1216
  if (l.label) {
@@ -792,8 +1218,8 @@
792
1218
  lab.dataset.i = String(i);
793
1219
  lab.dataset.a = l.from;
794
1220
  lab.dataset.b = l.to;
795
- lab.style.left = (Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
796
- lab.style.top = (y1 + y2) / 2 + "px";
1221
+ 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";
1222
+ 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";
797
1223
  els.world.appendChild(lab);
798
1224
  ctx.labelEls[i] = lab;
799
1225
  }
@@ -802,28 +1228,37 @@
802
1228
  function paint(ctx, fromSwitch = false) {
803
1229
  const { model } = ctx;
804
1230
  const mode = ctx.mode;
805
- ctx.geom = computeGeometry(model, { visible: (id) => {
1231
+ const effective = effectiveLinks(model, mode, ctx.collapsed, ctx.nodes);
1232
+ const effectiveById = new Map(effective.map((l) => [l.id, l]));
1233
+ ctx.geom = computeGeometry({ ...model, links: effective }, { visible: (id) => {
806
1234
  const n = ctx.nodes.get(id);
807
- return !!n && nodeVisible(model, mode, n);
1235
+ return !!n && nodeVisible(model, mode, n, ctx.collapsed);
808
1236
  } });
1237
+ ctx.els.world.style.height = ctx.geom.world.h + "px";
1238
+ ctx.els.svg.setAttribute("height", String(ctx.geom.world.h));
809
1239
  document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
810
1240
  for (const n of model.nodes) {
811
1241
  const d = ctx.nodeEls.get(n.id);
812
1242
  if (!d) continue;
1243
+ const rect = ctx.geom.nodes[n.id];
1244
+ if (rect) {
1245
+ d.style.left = rect.x + "px";
1246
+ d.style.top = rect.y + "px";
1247
+ }
813
1248
  const sid = nodeStateId(model, mode, n);
814
- const show = nodeVisible(model, mode, n);
1249
+ const show = nodeVisible(model, mode, n, ctx.collapsed);
815
1250
  const bucket = bucketOf(model, sid);
816
1251
  const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
817
1252
  const fresh = isNew(model, mode, n);
818
- d.className = "node st-" + cssId(sid) + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "") + (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? cors > 1 ? " hascor2" : " hascor" : "") + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "");
1253
+ d.className = "node st-" + cssId(sid) + (n.kind === "chip" ? " ref" : "") + (n.kind === "gateway" ? " gateway" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "") + (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? cors > 1 ? " hascor2" : " hascor" : "") + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "") + (metricBadge(n.metrics) ? " has-metrics" : "");
819
1254
  d.innerHTML = nodeHtml(ctx, n);
820
1255
  d.setAttribute("aria-hidden", show ? "false" : "true");
821
1256
  d.tabIndex = show ? 0 : -1;
822
1257
  const word = model.states[sid]?.word ?? sid;
823
1258
  const lane = model.lanes.find((l) => l.id === n.lane)?.title ?? n.lane;
824
- d.setAttribute("aria-label", n.kind === "chip" ? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.` : `${n.title}. ${n.subtitle ?? model.columns[n.col]?.title ?? ""}, ${word}. Press Enter to inspect.`);
1259
+ d.setAttribute("aria-label", n.kind === "chip" ? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.` : `${n.title}. ${n.subtitle ?? model.columns[n.col ?? 0]?.title ?? ""}, ${word}. Press Enter to inspect.`);
825
1260
  if (fromSwitch && fresh && !ctx.reduced) {
826
- d.style.transitionDelay = n.col * 60 + "ms";
1261
+ d.style.transitionDelay = (n.col ?? 0) * 60 + "ms";
827
1262
  d.classList.add("pulse");
828
1263
  window.setTimeout(() => {
829
1264
  d.style.transitionDelay = "";
@@ -836,8 +1271,24 @@
836
1271
  ctx.links.forEach((l, i) => {
837
1272
  const p = ctx.edgeEls[i];
838
1273
  if (!p) return;
839
- const vis = linkVisible(model, mode, l, ctx.nodes);
840
- p.setAttribute("class", "edge st-" + cssId(linkStateId(model, mode, l)) + (vis ? "" : " off"));
1274
+ const edge = effectiveById.get(l.id);
1275
+ const vis = !!edge;
1276
+ const a = edge && ctx.geom.nodes[edge.from], b = edge && ctx.geom.nodes[edge.to];
1277
+ const loop = edge?.kind === "loop-back";
1278
+ if (a && b) {
1279
+ p.dataset.a = edge.from;
1280
+ p.dataset.b = edge.to;
1281
+ const channel = ctx.geom.loops[edge.id]?.channelY ?? Math.max(a.y + a.h, b.y + b.h) + 10;
1282
+ p.setAttribute("d", loop ? loopRoute(ctx, edge.from, edge.to, a, b, channel) : 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));
1283
+ const lab = ctx.labelEls[i];
1284
+ if (lab) {
1285
+ lab.dataset.a = edge.from;
1286
+ lab.dataset.b = edge.to;
1287
+ 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";
1288
+ lab.style.top = (loop ? channel : (a.y + a.h / 2 + b.y + b.h / 2) / 2) + "px";
1289
+ }
1290
+ }
1291
+ p.setAttribute("class", "edge st-" + cssId(edge?.state ?? linkStateId(model, mode, l)) + (loop ? " loop" : "") + (vis ? "" : " off"));
841
1292
  ctx.labelEls[i]?.classList.toggle("off", !vis);
842
1293
  });
843
1294
  for (const l of model.lanes) {
@@ -960,6 +1411,7 @@
960
1411
  }
961
1412
  function placeHeaders() {
962
1413
  const cols = els.cols;
1414
+ cols.style.display = model.columns.length ? "" : "none";
963
1415
  if (!cols.firstChild) {
964
1416
  model.columns.forEach((c, i2) => {
965
1417
  const h = el("div", "hd");
@@ -1165,7 +1617,13 @@
1165
1617
  } else if (k === "n" || k === "N") ctx.fn.toggleNotes();
1166
1618
  else if (k === "v" || k === "V") setTool("select");
1167
1619
  else if (k === "h" || k === "H") setTool("hand");
1168
- else if (k === "+" || k === "=") zoomBy(1.25);
1620
+ else if (k === "e" || k === "E") {
1621
+ const selected = ctx.selected && ctx.nodes.get(ctx.selected);
1622
+ if (selected) {
1623
+ const group = selected.kind === "group" ? selected : ctx.model.nodes.find((n) => n.kind === "group" && n.contains?.includes(selected.id));
1624
+ if (group) ctx.fn.toggleGroup(group.id);
1625
+ }
1626
+ } else if (k === "+" || k === "=") zoomBy(1.25);
1169
1627
  else if (k === "-" || k === "_") zoomBy(1 / 1.25);
1170
1628
  else if (k === "0") {
1171
1629
  ctx.userMoved = false;
@@ -1219,6 +1677,7 @@
1219
1677
  ctx.fn.zoomToPath();
1220
1678
  }));
1221
1679
  box.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => ctx.fn.simulate(b.dataset.act ?? "")));
1680
+ box.querySelectorAll("[data-toggle-group]").forEach((b) => b.addEventListener("click", () => ctx.fn.toggleGroup(b.dataset.toggleGroup ?? "")));
1222
1681
  box.querySelector("#simReset")?.addEventListener("click", () => {
1223
1682
  ctx.fn.clearSim();
1224
1683
  ctx.fn.renderInspector();
@@ -1227,7 +1686,7 @@
1227
1686
  box.querySelector("#zPath")?.addEventListener("click", () => ctx.fn.zoomToPath());
1228
1687
  box.querySelector("#zBand")?.addEventListener("click", () => {
1229
1688
  const n = ctx.selected ? ctx.nodes.get(ctx.selected) : void 0;
1230
- if (n) ctx.fn.zoomToBand(n.lane);
1689
+ if (n?.lane) ctx.fn.zoomToBand(n.lane);
1231
1690
  });
1232
1691
  }
1233
1692
  function renderInspector() {
@@ -1235,7 +1694,7 @@
1235
1694
  const sel = ctx.selected ? ctx.nodes.get(ctx.selected) : void 0;
1236
1695
  const head = `<div class="insp-h"><span>Inspector</span><button type="button" id="hideInsp" aria-label="Close the inspector">Close \u2715</button></div>`;
1237
1696
  if (!sel) {
1238
- const found = ctx.query && !ctx.dismissed ? model.nodes.filter((n) => nodeVisible(model, ctx.mode, n) && matches(ctx, n)) : [];
1697
+ const found = ctx.query && !ctx.dismissed ? model.nodes.filter((n) => nodeVisible(model, ctx.mode, n, ctx.collapsed) && matches(ctx, n)) : [];
1239
1698
  const open = found.length > 0;
1240
1699
  box.classList.toggle("open", open);
1241
1700
  ws.classList.toggle("inspopen", open);
@@ -1244,7 +1703,7 @@
1244
1703
  const rows = found.map((f) => {
1245
1704
  const b = bucketOf(model, nodeStateId(model, ctx.mode, f));
1246
1705
  const hits = (f.members ?? []).filter((m) => m.toLowerCase().includes(ctx.query)).length;
1247
- 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 \xB7 ${esc(model.columns[f.col]?.title ?? "")}</span></span></button>`;
1706
+ 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 \xB7 ${esc(model.columns[f.col ?? 0]?.title ?? "")}</span></span></button>`;
1248
1707
  }).join("");
1249
1708
  box.innerHTML = head + `<div class="insp-b"><div class="insp-sec"><b>${found.length}</b> group${found.length === 1 ? "" : "s"} match \u201C${esc(ctx.query)}\u201D</div><div class="drill">${rows}</div></div>`;
1250
1709
  wire(box);
@@ -1273,10 +1732,23 @@
1273
1732
  const pills = `<span class="pill ${PILL[st?.tone ?? "muted"]}">${esc(st?.word ?? sid)}</span>` + (isImproved(model, ctx.mode, sel) ? '<span class="pill info">gap filled</span>' : "") + (isNew(model, ctx.mode, sel) ? '<span class="pill muted">new in this view</span>' : "");
1274
1733
  const actions = (model.inspector?.actions ?? []).map((a) => `<button type="button" class="danger" data-act="${esc(a.id)}">\u25B6 ${esc(a.label)}</button>`).join("");
1275
1734
  const members = sel.members ?? [];
1735
+ const owner = groupFor(model, sel.id);
1736
+ const foldAction = sel.kind === "group" && sel.contains?.length && ctx.collapsed.has(sel.id) ? `<button type="button" data-toggle-group="${esc(sel.id)}">Expand group</button>` : owner && !ctx.collapsed.has(owner.id) ? `<button type="button" data-toggle-group="${esc(owner.id)}">Collapse into ${esc(owner.title)}</button>` : "";
1737
+ const exits = sel.kind === "gateway" ? effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes).filter((l) => l.from === sel.id).map((l) => {
1738
+ const target = ctx.nodes.get(l.to);
1739
+ 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>`;
1740
+ }).join("") : "";
1741
+ const metric = sel.metrics;
1742
+ const series = metric?.series ?? [];
1743
+ const points = sparklinePoints(series, 300, 40);
1744
+ 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>` : "";
1276
1745
  box.innerHTML = head + `<div class="counts">${counts}</div>
1277
- <div class="insp-b"><div class="insp-sec"><p class="nname">${esc(sel.title)}</p><div class="nkind">${esc(model.columns[sel.col]?.title ?? "")} \xB7 ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
1746
+ <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 ?? "")} \xB7 ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
1278
1747
  <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>
1748
+ ${metricHtml}
1749
+ ${sel.kind === "gateway" ? `<div class="insp-sec"><b>Exits</b><div class="drill">${exits}</div></div>` : ""}
1279
1750
  ${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\u2026" 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>`}
1751
+ ${foldAction ? `<div class="acts">${foldAction}</div>` : ""}
1280
1752
  <div class="acts"><button type="button" id="zPath">Zoom to path</button><button type="button" id="zBand">Go to lane</button></div>
1281
1753
  ${actions ? `<div class="acts">${actions}<button type="button" id="simReset"${ctx.simActive ? "" : " hidden"}>Reset</button></div>` : ""}</div>`;
1282
1754
  wire(box);
@@ -1312,7 +1784,7 @@
1312
1784
  const label = (k) => model.meta.labels?.[k] ?? DEFAULT_LABELS[k] ?? k;
1313
1785
  const visible = (id) => {
1314
1786
  const n = ctx.nodes.get(id);
1315
- return !!n && nodeVisible(model, ctx.mode, n);
1787
+ return !!n && nodeVisible(model, ctx.mode, n, ctx.collapsed);
1316
1788
  };
1317
1789
  function countUp(node, to, ms = 700) {
1318
1790
  if (ctx.reduced) {
@@ -1329,6 +1801,7 @@
1329
1801
  }
1330
1802
  function buildStrip() {
1331
1803
  const box = els.strip;
1804
+ box.style.display = model.columns.length ? "" : "none";
1332
1805
  box.style.gridTemplateColumns = `repeat(${Math.max(1, model.columns.length)}, minmax(0, 1fr))`;
1333
1806
  if (!box.firstChild) {
1334
1807
  box.innerHTML = model.columns.map((c) => `<div class="lcell"><div class="ln">${esc(c.title)}</div><div class="lb"><i class="good"></i><i class="neutral"></i><i class="gap"></i></div><div class="lv"></div></div>`).join("");
@@ -1338,9 +1811,9 @@
1338
1811
  let tot = 0, good = 0, neutral = 0, gap = 0;
1339
1812
  for (const n of model.nodes) {
1340
1813
  if (n.col !== i || n.kind === "chip") continue;
1814
+ if (!visible(n.id)) continue;
1341
1815
  const w = Math.max(1, n.weight ?? 1);
1342
1816
  tot += w;
1343
- if (!visible(n.id)) continue;
1344
1817
  const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
1345
1818
  if (b === "good") good += w;
1346
1819
  else if (b === "neutral") neutral += w;
@@ -1360,9 +1833,8 @@
1360
1833
  function buildTiles() {
1361
1834
  const k = els.tiles;
1362
1835
  let good = 0, neutral = 0;
1363
- for (const l of ctx.links) {
1364
- if (!linkVisible(model, ctx.mode, l, ctx.nodes)) continue;
1365
- const b = bucketOf(model, linkStateId(model, ctx.mode, l));
1836
+ for (const l of effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes)) {
1837
+ const b = bucketOf(model, l.state);
1366
1838
  if (b === "good") good++;
1367
1839
  else if (b === "neutral") neutral++;
1368
1840
  }
@@ -1418,7 +1890,7 @@
1418
1890
  const rows = model.nodes.filter((n) => visible(n.id) && n.kind !== "chip").map((n) => {
1419
1891
  const sid = nodeStateId(model, ctx.mode, n);
1420
1892
  const lane = model.lanes.find((l) => l.id === n.lane)?.title ?? n.lane;
1421
- return `<tr><th scope="row">${esc(model.columns[n.col]?.title ?? "")}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
1893
+ return `<tr><th scope="row">${esc(columnTitle(model, n))}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
1422
1894
  }).join("");
1423
1895
  els.table.innerHTML = `<thead><tr><th scope="col">Column</th><th scope="col">Lane</th><th scope="col">Group</th><th scope="col">State</th><th scope="col">Members</th><th scope="col">Detail</th><th scope="col">Can improve</th></tr></thead><tbody>${rows}</tbody>`;
1424
1896
  }
@@ -1460,6 +1932,10 @@
1460
1932
  ctx.fn.renderInspector();
1461
1933
  }
1462
1934
  });
1935
+ on(ctx, els.hops, "change", () => {
1936
+ ctx.hops = Number(els.hops.value);
1937
+ ctx.fn.trace(ctx.selected);
1938
+ });
1463
1939
  on(ctx, els.q, "keydown", (e) => {
1464
1940
  if (e.key !== "Enter" || !ctx.query) return;
1465
1941
  const ids = model.nodes.filter((x) => visible(x.id) && matches(ctx, x)).map((x) => x.id);
@@ -1499,6 +1975,11 @@
1499
1975
  const a = anchorOf(n.anchor);
1500
1976
  if (!a) continue;
1501
1977
  let r = a.getBoundingClientRect();
1978
+ if (!r.width && !r.height) {
1979
+ let d = a.closest("details");
1980
+ while (d && d.open) d = d.parentElement?.closest("details") ?? null;
1981
+ if (d) r = d.getBoundingClientRect();
1982
+ }
1502
1983
  if (!r.width && !r.height) continue;
1503
1984
  if (a.id === "inspector" && !a.classList.contains("open")) {
1504
1985
  const vr = els.vp.getBoundingClientRect();
@@ -1588,6 +2069,365 @@
1588
2069
  ctx.fn.placeMarkers = placeMarkers;
1589
2070
  }
1590
2071
 
2072
+ // src/playback.ts
2073
+ function planScenario(model, scenario) {
2074
+ const source = scenario.steps ?? [];
2075
+ const mode = source.some((s) => s?.at !== void 0) ? "elapsed" : "none";
2076
+ const start = source[0]?.at ?? 0;
2077
+ const scale = mode === "elapsed" ? Math.max(1, ...source.slice(1).map((s, i) => {
2078
+ const gap = ((s?.at ?? 0) - (source[i]?.at ?? 0)) * 1e3;
2079
+ return gap > 0 ? (source[i]?.dur ?? 900) / gap : 1;
2080
+ })) : 1;
2081
+ const steps = [];
2082
+ for (let i = 0; i < source.length; i++) {
2083
+ const s = source[i];
2084
+ if (!s || typeof s !== "object") continue;
2085
+ const link = model.links.find((l) => l.id === s.link);
2086
+ if (!link) continue;
2087
+ const from = model.nodes.find((n) => n.id === link.from);
2088
+ const to = model.nodes.find((n) => n.id === link.to);
2089
+ const dur = Number.isFinite(s.dur) && (s.dur ?? 0) > 0 ? s.dur : 900;
2090
+ const proposed = mode === "elapsed" ? Math.max(0, ((s.at ?? 0) - start) * 1e3 * scale) : 0;
2091
+ const at = steps.length === 0 ? 0 : Math.max(proposed, steps.at(-1).at + steps.at(-1).dur);
2092
+ steps.push({ link: s.link, from: from?.ref ?? link.from, to: to?.ref ?? link.to, at, dur, caption: s.caption ?? `${from?.title ?? link.from} to ${to?.title ?? link.to}`, index: i });
2093
+ }
2094
+ return { steps, mode, totalMs: steps.length ? steps.at(-1).at + steps.at(-1).dur : 0 };
2095
+ }
2096
+ function positionAt(plan, tMs) {
2097
+ if (!plan.steps.length) return { stepIndex: -1, progress: 0, done: true };
2098
+ const t = Number.isNaN(tMs) ? 0 : Math.max(0, tMs);
2099
+ if (t >= plan.totalMs) return { stepIndex: plan.steps.length - 1, progress: 1, done: true };
2100
+ let i = plan.steps.length - 1;
2101
+ while (i > 0 && t < plan.steps[i].at) i--;
2102
+ const s = plan.steps[i];
2103
+ return { stepIndex: i, progress: Math.max(0, Math.min(1, (t - s.at) / s.dur)), done: false };
2104
+ }
2105
+ function slaState(elapsedSec, sla) {
2106
+ if (elapsedSec >= sla) return "breach";
2107
+ return elapsedSec >= sla * 0.8 ? "warn" : "ok";
2108
+ }
2109
+ function formatElapsed(sec) {
2110
+ const n = Math.max(0, Math.round(Number.isFinite(sec) ? sec : 0));
2111
+ const m = Math.floor(n / 60), s = String(n % 60).padStart(2, "0");
2112
+ return n < 3600 ? `${m}:${s}` : `${Math.floor(n / 3600)}:${String(m % 60).padStart(2, "0")}:${s}`;
2113
+ }
2114
+ function exitsOf(model, nodeId) {
2115
+ const canon = (id) => model.nodes.find((n) => n.id === id)?.ref ?? id;
2116
+ return model.links.filter((l) => canon(l.from) === canon(nodeId));
2117
+ }
2118
+ function suggestedExit(exits, nextLink) {
2119
+ return exits.find((l) => l.id === nextLink);
2120
+ }
2121
+ function buildManualRoute(model, choiceLink) {
2122
+ const route = [choiceLink];
2123
+ const seen = /* @__PURE__ */ new Set([choiceLink.id]);
2124
+ let at = choiceLink.to;
2125
+ while (exitsOf(model, at).length === 1) {
2126
+ const next = exitsOf(model, at)[0];
2127
+ if (seen.has(next.id)) break;
2128
+ route.push(next);
2129
+ seen.add(next.id);
2130
+ at = next.to;
2131
+ }
2132
+ return route;
2133
+ }
2134
+ function extendScenarioWithRoute(scenario, plan, stepIndex, route) {
2135
+ const isElapsed = plan.mode === "elapsed" || scenario.steps.some((s) => s?.at !== void 0);
2136
+ const prefix = scenario.steps.slice(0, stepIndex + 1).map((s) => ({ ...s }));
2137
+ for (let i = prefix.length; i <= stepIndex && i < plan.steps.length; i++) {
2138
+ const ps = plan.steps[i];
2139
+ prefix.push({ link: ps.link, dur: ps.dur, caption: ps.caption });
2140
+ }
2141
+ let lastAt = 0;
2142
+ if (isElapsed) {
2143
+ const lastWithAt = [...prefix].reverse().find((s) => s?.at !== void 0);
2144
+ lastAt = lastWithAt?.at ?? 0;
2145
+ }
2146
+ const newSteps = route.map((l) => {
2147
+ const step = { link: l.id };
2148
+ if (isElapsed) {
2149
+ lastAt += 1;
2150
+ step.at = lastAt;
2151
+ }
2152
+ return step;
2153
+ });
2154
+ return {
2155
+ ...scenario,
2156
+ steps: [...prefix, ...newSteps]
2157
+ };
2158
+ }
2159
+
2160
+ // src/playbackui.ts
2161
+ function install7(ctx) {
2162
+ const e = ctx.els;
2163
+ const scenarios = ctx.model.scenarios ?? [];
2164
+ e.playbar.hidden = !scenarios.length;
2165
+ e.pbScenario.replaceChildren(...scenarios.map((s) => {
2166
+ const o = document.createElement("option");
2167
+ o.value = s.id;
2168
+ o.textContent = s.label;
2169
+ return o;
2170
+ }));
2171
+ let scenario = scenarios[0];
2172
+ let plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
2173
+ let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
2174
+ let markedEdges = [];
2175
+ let markedNodes = [];
2176
+ const token = sv("circle", { id: "token", class: "token", r: "6" });
2177
+ e.layer.appendChild(token);
2178
+ const pathOf = (link) => ctx.edgeEls[ctx.links.findIndex((l) => l.id === link)];
2179
+ const nodeOf = (id) => ctx.nodeEls.get(id);
2180
+ const clearMarks = () => {
2181
+ for (const p of markedEdges) p.classList.remove("playing", "visited");
2182
+ for (const n of markedNodes) n.classList.remove("playing", "visited");
2183
+ markedEdges = [];
2184
+ markedNodes = [];
2185
+ };
2186
+ function stop() {
2187
+ playing = false;
2188
+ cancelAnimationFrame(frame);
2189
+ clearTimeout(timer);
2190
+ document.body.classList.remove("playing");
2191
+ e.pbPlay.setAttribute("aria-label", "Play");
2192
+ e.pbPlay.textContent = "\u25B6";
2193
+ }
2194
+ function pause() {
2195
+ if (!playing) return;
2196
+ stop();
2197
+ const p = positionAt(plan, t);
2198
+ draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
2199
+ }
2200
+ function choices(step2) {
2201
+ if (playing || !step2) {
2202
+ if (choicesKey !== "") {
2203
+ e.pbChoices.replaceChildren();
2204
+ choicesKey = "";
2205
+ }
2206
+ return;
2207
+ }
2208
+ const key = `${scenario?.id ?? ""}|${idx}|${plan.steps.length}|${plan.steps[idx + 1]?.link ?? ""}`;
2209
+ if (key === choicesKey) return;
2210
+ choicesKey = key;
2211
+ e.pbChoices.replaceChildren();
2212
+ const exits = exitsOf(ctx.model, step2.to);
2213
+ if (exits.length < 2) return;
2214
+ const suggested = suggestedExit(exits, plan.steps[idx + 1]?.link);
2215
+ for (const link of exits) {
2216
+ const b = document.createElement("button");
2217
+ b.type = "button";
2218
+ b.textContent = link.label ?? ctx.nodes.get(link.to)?.title ?? link.to;
2219
+ b.dataset.link = link.id;
2220
+ if (link.id === suggested?.id) b.classList.add("suggested");
2221
+ b.addEventListener("click", () => {
2222
+ if (!scenario) return;
2223
+ const route = buildManualRoute(ctx.model, link);
2224
+ scenario = extendScenarioWithRoute(scenario, plan, idx, route);
2225
+ plan = planScenario(ctx.model, scenario);
2226
+ t = plan.steps[idx + 1]?.at ?? 0;
2227
+ play();
2228
+ });
2229
+ e.pbChoices.appendChild(b);
2230
+ }
2231
+ }
2232
+ function draw(stepIndex, progress) {
2233
+ const step2 = plan.steps[stepIndex];
2234
+ clearMarks();
2235
+ if (!step2) {
2236
+ token.style.display = "none";
2237
+ return;
2238
+ }
2239
+ token.style.display = "";
2240
+ for (let i = 0; i < stepIndex; i++) {
2241
+ const s = plan.steps[i];
2242
+ const p = pathOf(s.link);
2243
+ if (p) {
2244
+ p.classList.add("visited");
2245
+ markedEdges.push(p);
2246
+ }
2247
+ const n = nodeOf(s.to);
2248
+ if (n) {
2249
+ n.classList.add("visited");
2250
+ markedNodes.push(n);
2251
+ }
2252
+ }
2253
+ const path = pathOf(step2.link);
2254
+ if (path) {
2255
+ path.classList.add("playing");
2256
+ markedEdges.push(path);
2257
+ }
2258
+ const node = nodeOf(step2.to);
2259
+ if (node) {
2260
+ node.classList.add("playing");
2261
+ markedNodes.push(node);
2262
+ }
2263
+ if (path && !path.classList.contains("off")) {
2264
+ const p = path.getPointAtLength(path.getTotalLength() * progress);
2265
+ token.setAttribute("cx", String(p.x));
2266
+ token.setAttribute("cy", String(p.y));
2267
+ } else token.style.display = "none";
2268
+ e.pbCaption.textContent = step2.caption;
2269
+ e.pbScrub.value = String(plan.totalMs ? Math.round(t / plan.totalMs * 1e3) : 0);
2270
+ e.pbScrub.setAttribute("aria-valuetext", `Step ${stepIndex + 1} of ${plan.steps.length}: ${step2.caption}`);
2271
+ e.pbCounter.className = "";
2272
+ e.pbCounter.hidden = plan.mode === "none";
2273
+ if (plan.mode === "elapsed") {
2274
+ const sec = scenario?.steps[step2.index]?.at ?? 0;
2275
+ const sla = ctx.nodes.get(step2.to)?.sla;
2276
+ e.pbCounter.textContent = sla === void 0 ? formatElapsed(sec) : `${formatElapsed(sec)} / ${formatElapsed(sla)}`;
2277
+ if (sla !== void 0) e.pbCounter.classList.add(slaState(sec, sla));
2278
+ }
2279
+ if (idx !== stepIndex) ctx.fn.announce(step2.caption);
2280
+ idx = stepIndex;
2281
+ choices(step2);
2282
+ }
2283
+ function rewind() {
2284
+ stop();
2285
+ t = 0;
2286
+ idx = -1;
2287
+ clearMarks();
2288
+ choicesKey = "";
2289
+ e.pbCaption.textContent = "";
2290
+ e.pbCounter.hidden = true;
2291
+ e.pbCounter.className = "";
2292
+ e.pbChoices.replaceChildren();
2293
+ token.style.display = "none";
2294
+ e.pbScrub.value = "0";
2295
+ e.pbScrub.setAttribute("aria-valuetext", "");
2296
+ }
2297
+ function select(id) {
2298
+ scenario = scenarios.find((s) => s.id === id) ?? scenarios[0];
2299
+ plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
2300
+ e.pbScenario.value = scenario?.id ?? "";
2301
+ rewind();
2302
+ }
2303
+ function seek(value) {
2304
+ stop();
2305
+ t = Math.max(0, Math.min(plan.totalMs, Number.isFinite(value) ? value : 0));
2306
+ const p = positionAt(plan, t);
2307
+ draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
2308
+ }
2309
+ function tick(now) {
2310
+ if (!playing) return;
2311
+ if (!stamp) stamp = now;
2312
+ const delta = (now - stamp) * Number(e.pbSpeed.value || 1);
2313
+ stamp = now;
2314
+ t = Math.min(plan.totalMs, t + delta);
2315
+ const p = positionAt(plan, t);
2316
+ if (p.done) {
2317
+ stop();
2318
+ draw(p.stepIndex, 1);
2319
+ return;
2320
+ }
2321
+ draw(p.stepIndex, p.progress);
2322
+ frame = requestAnimationFrame(tick);
2323
+ }
2324
+ function reducedStep() {
2325
+ if (!playing) return;
2326
+ const next = Math.min(plan.steps.length - 1, idx + 1);
2327
+ if (next < 0) {
2328
+ stop();
2329
+ return;
2330
+ }
2331
+ t = plan.steps[next].at + plan.steps[next].dur;
2332
+ if (next === plan.steps.length - 1) stop();
2333
+ draw(next, 1);
2334
+ if (playing) timer = window.setTimeout(reducedStep, 1e3 / Number(e.pbSpeed.value || 1));
2335
+ }
2336
+ function play(id) {
2337
+ if (id && id !== scenario?.id) select(id);
2338
+ stop();
2339
+ if (!plan.steps.length) return;
2340
+ if (t >= plan.totalMs) {
2341
+ t = 0;
2342
+ idx = -1;
2343
+ }
2344
+ playing = true;
2345
+ document.body.classList.add("playing");
2346
+ e.pbPlay.setAttribute("aria-label", "Pause");
2347
+ e.pbPlay.textContent = "\u275A\u275A";
2348
+ e.pbChoices.replaceChildren();
2349
+ choicesKey = "";
2350
+ if (ctx.reduced) reducedStep();
2351
+ else {
2352
+ stamp = 0;
2353
+ frame = requestAnimationFrame(tick);
2354
+ }
2355
+ }
2356
+ function step() {
2357
+ stop();
2358
+ const next = Math.min(idx + 1, plan.steps.length - 1);
2359
+ if (next < 0) return;
2360
+ t = plan.steps[next].at + plan.steps[next].dur;
2361
+ draw(next, 1);
2362
+ }
2363
+ on(ctx, e.pbScenario, "change", () => select(e.pbScenario.value));
2364
+ on(ctx, e.pbPlay, "click", () => playing ? pause() : play());
2365
+ on(ctx, e.pbStep, "click", step);
2366
+ on(ctx, e.pbReset, "click", () => rewind());
2367
+ on(ctx, e.pbScrub, "input", () => seek(Number(e.pbScrub.value) / 1e3 * plan.totalMs));
2368
+ on(ctx, document, "keydown", (event) => {
2369
+ if (event.ctrlKey || event.metaKey || event.altKey || event.target instanceof HTMLElement && (event.target.isContentEditable || /INPUT|TEXTAREA|SELECT/.test(event.target.tagName))) return;
2370
+ if (event.key.toLowerCase() === "p") {
2371
+ event.preventDefault();
2372
+ playing ? pause() : play();
2373
+ }
2374
+ if (event.key === ".") {
2375
+ event.preventDefault();
2376
+ step();
2377
+ }
2378
+ });
2379
+ ctx.disposers.push(() => {
2380
+ stop();
2381
+ token.remove();
2382
+ });
2383
+ select(scenario?.id ?? "");
2384
+ return {
2385
+ play,
2386
+ pause,
2387
+ step,
2388
+ seek,
2389
+ playback: () => ({ scenario: scenario?.id ?? null, step: idx, playing, t }),
2390
+ restore: (state) => {
2391
+ if (!state.scenario || !scenarios.some((s) => s.id === state.scenario)) return;
2392
+ select(state.scenario);
2393
+ const current = plan.steps[state.step];
2394
+ if (!current) return;
2395
+ t = Math.max(current.at, Math.min(current.at + current.dur, state.t));
2396
+ draw(state.step, ctx.reduced ? 1 : current.dur > 0 ? (t - current.at) / current.dur : 1);
2397
+ if (state.playing) play();
2398
+ },
2399
+ reset: () => rewind()
2400
+ };
2401
+ }
2402
+
2403
+ // src/diff.ts
2404
+ var fields = ["state", "stateIn", "title", "subtitle", "weight", "metrics", "visibleIn", "lane", "sla"];
2405
+ function stableStringify(val) {
2406
+ if (val === void 0) return "undefined";
2407
+ if (val === null) return "null";
2408
+ if (typeof val === "number") return Number.isFinite(val) ? JSON.stringify(val) : String(val);
2409
+ if (typeof val === "bigint") return String(val);
2410
+ if (typeof val === "object") {
2411
+ if (Array.isArray(val)) return `[${val.map(stableStringify).join(",")}]`;
2412
+ const rec = val;
2413
+ const keys = Object.keys(rec).filter((k) => rec[k] !== void 0).sort();
2414
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
2415
+ }
2416
+ return JSON.stringify(val) ?? "undefined";
2417
+ }
2418
+ function diffModels(a, b) {
2419
+ const old = new Map(a.nodes.map((n) => [n.id, n]));
2420
+ const now = new Map(b.nodes.map((n) => [n.id, n]));
2421
+ return {
2422
+ added: b.nodes.filter((n) => !old.has(n.id)).map((n) => n.id),
2423
+ removed: a.nodes.filter((n) => !now.has(n.id)).map((n) => n.id),
2424
+ changed: b.nodes.filter((n) => {
2425
+ const prev = old.get(n.id);
2426
+ return prev && fields.some((k) => stableStringify(prev[k]) !== stableStringify(n[k]));
2427
+ }).map((n) => n.id)
2428
+ };
2429
+ }
2430
+
1591
2431
  // src/engine.ts
1592
2432
  var IDS = {
1593
2433
  vp: "viewport",
@@ -1606,6 +2446,7 @@
1606
2446
  ledger: "ledger",
1607
2447
  table: "tbl",
1608
2448
  q: "q",
2449
+ hops: "hops",
1609
2450
  matchinfo: "matchinfo",
1610
2451
  modeSwitch: "modeSwitch",
1611
2452
  drawer: "drawer",
@@ -1617,7 +2458,18 @@
1617
2458
  title: "title",
1618
2459
  readat: "readat",
1619
2460
  help: "help",
1620
- footer: "footer"
2461
+ footer: "footer",
2462
+ playbar: "playbar",
2463
+ pbScenario: "pbScenario",
2464
+ pbPlay: "pbPlay",
2465
+ pbStep: "pbStep",
2466
+ pbReset: "pbReset",
2467
+ pbSpeed: "pbSpeed",
2468
+ pbScrub: "pbScrub",
2469
+ pbCaption: "pbCaption",
2470
+ pbCounter: "pbCounter",
2471
+ pbChoices: "pbChoices",
2472
+ banner: "banner"
1621
2473
  };
1622
2474
  function findEls(root) {
1623
2475
  const out = {};
@@ -1632,6 +2484,7 @@
1632
2484
  if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
1633
2485
  throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
1634
2486
  }
2487
+ if (model.layout === "auto") model = autoLayout(model);
1635
2488
  const els = findEls(root);
1636
2489
  const issues = validateModel(model);
1637
2490
  const errors = issues.filter((i) => i.level === "error");
@@ -1642,17 +2495,20 @@
1642
2495
  model,
1643
2496
  root,
1644
2497
  els,
2498
+ onSelect: opts.onSelect,
1645
2499
  notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
1646
2500
  nodes: new Map(model.nodes.map((n) => [n.id, n])),
1647
2501
  links: model.links,
1648
- geom: { xs: [], nodes: {}, lanes: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
2502
+ geom: { xs: [], nodes: {}, lanes: {}, loops: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
1649
2503
  view: { x: 0, y: 0, k: 1 },
1650
2504
  reduced: rm.matches,
1651
- mode: defaultView(model),
1652
- selected: null,
2505
+ mode: opts.restore && (model.views?.some((v) => v.id === opts.restore.mode) || !model.views?.length && opts.restore.mode === "all") ? opts.restore.mode : defaultView(model),
2506
+ 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),
2507
+ selected: opts.restore?.selected && model.nodes.some((n) => n.id === opts.restore.selected) ? opts.restore.selected : null,
1653
2508
  hl: null,
1654
- query: "",
1655
- filter: null,
2509
+ query: opts.restore?.query ?? "",
2510
+ hops: opts.restore?.hops ?? 0,
2511
+ filter: opts.restore?.filter ?? null,
1656
2512
  tool: "select",
1657
2513
  spaceDown: false,
1658
2514
  userMoved: false,
@@ -1686,12 +2542,38 @@
1686
2542
  install4(ctx);
1687
2543
  install5(ctx);
1688
2544
  install6(ctx);
1689
- document.body.classList.add("intro");
1690
- window.setTimeout(() => document.body.classList.remove("intro"), 2100);
2545
+ els.q.value = ctx.query;
2546
+ els.hops.value = String(ctx.hops);
1691
2547
  ctx.fn.buildWorld();
1692
2548
  ctx.fn.paint(false);
1693
- ctx.fn.fitWidth(false);
1694
- window.setTimeout(() => ctx.fn.fitWidth(false), 120);
2549
+ const playback = install7(ctx);
2550
+ if (opts.restore) {
2551
+ ctx.userMoved = true;
2552
+ ctx.fn.setView(opts.restore.view, false);
2553
+ playback.restore(opts.restore.playback);
2554
+ if (opts.restore.inspectorOpen) {
2555
+ els.inspector.classList.add("open");
2556
+ els.workspace.classList.add("inspopen");
2557
+ els.inspector.setAttribute("aria-hidden", "false");
2558
+ ctx.fn.renderInspector();
2559
+ } else {
2560
+ els.inspector.classList.remove("open");
2561
+ els.workspace.classList.remove("inspopen");
2562
+ els.inspector.setAttribute("aria-hidden", "true");
2563
+ }
2564
+ } else {
2565
+ document.body.classList.add("intro");
2566
+ const introTimer = window.setTimeout(() => document.body.classList.remove("intro"), 2100);
2567
+ ctx.disposers.push(() => {
2568
+ window.clearTimeout(introTimer);
2569
+ document.body.classList.remove("intro");
2570
+ });
2571
+ ctx.fn.fitWidth(false);
2572
+ const fitTimer = window.setTimeout(() => {
2573
+ if (!ctx.userMoved) ctx.fn.fitWidth(false);
2574
+ }, 120);
2575
+ ctx.disposers.push(() => window.clearTimeout(fitTimer));
2576
+ }
1695
2577
  const api = {
1696
2578
  model,
1697
2579
  geometry: () => ctx.geom,
@@ -1710,17 +2592,48 @@
1710
2592
  ctx.fn.clearSim();
1711
2593
  ctx.fn.renderInspector();
1712
2594
  },
2595
+ play: (id) => playback.play(id),
2596
+ pause: () => playback.pause(),
2597
+ step: () => playback.step(),
2598
+ seek: (t) => playback.seek(t),
2599
+ playback: () => playback.playback(),
2600
+ update: (nextModel, updateOpts) => {
2601
+ const prepared = nextModel.layout === "auto" ? autoLayout(nextModel) : nextModel;
2602
+ const nextIssues = validateModel(prepared);
2603
+ const count = nextIssues.filter((i) => i.level === "error").length;
2604
+ if (count) {
2605
+ els.banner.textContent = `Update failed: ${count} errors. Showing the last good graph.`;
2606
+ els.banner.hidden = false;
2607
+ return { ok: false, issues: nextIssues };
2608
+ }
2609
+ const changes = diffModels(ctx.model, prepared);
2610
+ const saved = { 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() };
2611
+ api.destroy();
2612
+ const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
2613
+ notifySelection(saved.selected, replacement.selected(), opts.onSelect);
2614
+ Object.assign(api, replacement);
2615
+ window.FlowStudio = api;
2616
+ els.banner.hidden = true;
2617
+ els.banner.textContent = "";
2618
+ if (updateOpts?.animate !== false && !ctx.reduced) {
2619
+ for (const id of changes.changed) root.querySelector(`#n_${CSS.escape(id)}`)?.classList.add("updated");
2620
+ for (const id of changes.added) root.querySelector(`#n_${CSS.escape(id)}`)?.classList.add("added");
2621
+ }
2622
+ return { ok: true, issues: nextIssues };
2623
+ },
1713
2624
  destroy: () => {
1714
2625
  ctx.fn.clearSim();
1715
2626
  ctx.disposers.forEach((d) => d());
1716
2627
  ctx.disposers = [];
1717
2628
  style?.remove();
1718
- if (window.FlowStudio === api) delete window.FlowStudio;
2629
+ if (window.FlowStudio === api || window.FlowStudio?.model === api.model) delete window.FlowStudio;
1719
2630
  }
1720
2631
  };
1721
2632
  window.FlowStudio = api;
1722
2633
  return api;
1723
2634
  }
2635
+
2636
+ // src/page.ts
1724
2637
  function auto() {
1725
2638
  const m = document.getElementById("flow-model");
1726
2639
  if (!m) return;