@caddis/cli 0.4.45 → 0.4.46

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