@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
@@ -0,0 +1,29 @@
1
+ import type { NodeMetric } from "./model.ts";
2
+ import { esc } from "./model.ts";
3
+
4
+ export function sparklinePoints(series: number[], width: number, height: number): string {
5
+ if (!Array.isArray(series) || series.length < 2 || series.some(v => !Number.isFinite(v))) return "";
6
+ const min = Math.min(...series), max = Math.max(...series), span = max - min;
7
+ return series.map((v, i) => `${Math.round(i / (series.length - 1) * width * 100) / 100},${Math.round((span ? 1 - (v - min) / span : .5) * height * 100) / 100}`).join(" ");
8
+ }
9
+
10
+ export function metricBadge(metric: NodeMetric | undefined): string {
11
+ if (!metric) return "";
12
+ const label = metric.label ?? "Metric";
13
+ const valStr = esc(String(metric.value));
14
+ const unitStr = metric.unit ? " " + esc(metric.unit) : "";
15
+ const title = `${esc(label)}: ${valStr}${unitStr}`;
16
+ const hasMax = metric.max != null && Number.isFinite(metric.max) && metric.max > 0;
17
+ const pct = hasMax && Number.isFinite(metric.value)
18
+ ? Math.max(0, Math.min(100, (metric.value / metric.max!) * 100))
19
+ : 0;
20
+ const bar = hasMax ? `<i style="width:${pct}%"></i>` : "";
21
+ return `<span class="metric-badge" title="${title}">${valStr}${unitStr}${bar}</span>`;
22
+ }
23
+
24
+ export function sparkline(metric: NodeMetric | undefined, width = 56, height = 12): string {
25
+ if (!metric?.series || metric.series.length < 2) return "";
26
+ const points = sparklinePoints(metric.series, width, height);
27
+ if (!points) return "";
28
+ return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
29
+ }
@@ -57,9 +57,30 @@ test("a state with an unknown tone is rejected", () => {
57
57
  const m = base(); (m.states.ok as any).tone = "purple";
58
58
  assert.ok(codes(m).includes("state-shape"));
59
59
  });
60
- test("only the columns-lanes layout exists in this stage", () => {
61
- const m = base(); (m as any).layout = "free";
60
+ test("free layout needs finite position and positive size but no columns", () => {
61
+ const m = base(); m.layout = "free"; m.columns = [];
62
+ m.nodes[0] = { id: "a", kind: "card", lane: "l1", x: 0, y: 0, w: 100, h: 40, state: "ok", title: "A" };
63
+ m.nodes[1] = { id: "b", kind: "card", lane: "l1", x: 200, y: 0, state: "ok", title: "B" };
64
+ assert.deepEqual(codes(m), []);
65
+ delete m.nodes[0]!.x;
66
+ assert.ok(codes(m).includes("position-invalid"));
67
+ m.nodes[0]!.x = 0; m.nodes[0]!.w = -1;
68
+ assert.ok(codes(m).includes("size-invalid"));
69
+ });
70
+ test("free layout detects rectangle intersections", () => {
71
+ const m = base(); m.layout = "free"; m.columns = [];
72
+ m.nodes[0] = { id: "a", kind: "card", lane: "l1", x: 0, y: 0, w: 100, h: 50, state: "ok", title: "A" };
73
+ m.nodes[1] = { id: "b", kind: "card", lane: "l1", x: 90, y: 49, w: 100, h: 50, state: "ok", title: "B" };
74
+ assert.ok(codes(m).includes("rect-overlap"));
75
+ m.nodes[1]!.x = 100;
76
+ assert.ok(!codes(m).includes("rect-overlap"));
77
+ });
78
+ test("unsupported layout is an error; auto permits missing positions before layout", () => {
79
+ const m = base(); (m as any).layout = "timeline";
62
80
  assert.ok(codes(m).includes("layout-unsupported"));
81
+ m.layout = "auto"; m.columns = []; m.lanes = [];
82
+ m.nodes.forEach(n => { delete n.col; delete n.row; delete n.lane; });
83
+ assert.ok(!codes(m).includes("layout-unsupported"));
63
84
  });
64
85
  test("a link that goes backward or sideways is a warning, not silence", () => {
65
86
  const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a";
@@ -70,6 +91,26 @@ test("a link that goes backward or sideways is a warning, not silence", () => {
70
91
  test("a forward link has no direction warning", () => {
71
92
  assert.ok(!validateModel(base()).some(i => i.code === "link-direction"));
72
93
  });
94
+ test("a branching gateway requires a label on every exit", () => {
95
+ const m = base(); m.nodes[0]!.kind = "gateway";
96
+ m.nodes.push({ id: "c", kind: "card", lane: "l1", col: 1, row: 1, state: "ok", title: "C" });
97
+ m.links.push({ id: "a-c", from: "a", to: "c", state: "ok", label: "No" });
98
+ assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "gateway-exit-label" && i.where === "nodes[a]"));
99
+ m.links[0]!.label = "Yes";
100
+ assert.ok(!validateModel(m).some(i => i.code === "gateway-exit-label"));
101
+ });
102
+ test("a gateway with no exit warns", () => {
103
+ const m = base(); m.nodes[1]!.kind = "gateway";
104
+ assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "gateway-no-exit" && i.where === "nodes[b]"));
105
+ });
106
+ test("an explicit loop-back suppresses backward direction warning", () => {
107
+ const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a"; m.links[0]!.kind = "loop-back";
108
+ assert.ok(!validateModel(m).some(i => i.code === "link-direction" || i.code === "loop-back-forward"));
109
+ });
110
+ test("a loop-back that runs forward warns", () => {
111
+ const m = base(); m.links[0]!.kind = "loop-back";
112
+ assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "loop-back-forward"));
113
+ });
73
114
  test("a model with no meta is an error, not a crash later", () => {
74
115
  const m = base(); delete (m as any).meta;
75
116
  assert.ok(codes(m).includes("meta-missing"));
@@ -101,7 +142,26 @@ test("a state id must be usable as a CSS class", () => {
101
142
  const m = base(); m.states['bad id"'] = m.states.ok!;
102
143
  assert.ok(codes(m).includes("state-id"));
103
144
  });
145
+ test("a metric max of null means no maximum; a non-numeric max is still an error", () => {
146
+ const m = base();
147
+ m.nodes[0]!.metrics = { value: 5, max: null };
148
+ assert.ok(!codes(m).includes("metric-invalid"), "null max is the author's way to say there is no maximum");
149
+ m.nodes[0]!.metrics = { value: 5, max: "10" as unknown as number };
150
+ assert.ok(codes(m).includes("metric-invalid"));
151
+ });
104
152
  test("esc turns markup into text", () => {
105
153
  assert.equal(esc(`<img src=x onerror="a()">&'`), "&lt;img src=x onerror=&quot;a()&quot;&gt;&amp;&#39;");
106
154
  assert.equal(esc(undefined), "");
107
155
  });
156
+ test("group containment validates ids, ownership, self-reference, and skips group-member overlap", () => {
157
+ const m = base();
158
+ const group = { id: "g", kind: "group" as const, lane: "l1", col: 0, row: 0, state: "ok", title: "G", contains: ["a"] };
159
+ m.nodes.push(group);
160
+ assert.ok(!codes(m).includes("cell-overlap"));
161
+ group.contains = ["a", "g", "missing"];
162
+ m.nodes.push({ ...group, id: "g2", contains: ["a"], row: 2 });
163
+ const found = codes(m);
164
+ assert.ok(found.includes("contains-self"));
165
+ assert.ok(found.includes("contains-undefined"));
166
+ assert.ok(found.includes("contains-duplicate"));
167
+ });
@@ -6,6 +6,7 @@ export type Border = "solid" | "dashed" | "dotted";
6
6
  export type EdgeStyle = "flow" | "dash" | "dash-short" | "dot";
7
7
  export type Motion = "flow" | "flow-slow" | "breathe" | "none";
8
8
  export type Bucket = "good" | "neutral" | "gap";
9
+ export const NODE_HEIGHT: Record<Kind, number> = { card: 54, group: 54, gateway: 64, placeholder: 54, chip: 40, start: 40, end: 40, event: 40, note: 40 };
9
10
 
10
11
  export interface StateDef {
11
12
  word: string;
@@ -20,10 +21,14 @@ export interface LaneDef { id: string; title: string; badge?: string; badgeTone?
20
21
  export interface NodeDef {
21
22
  id: string;
22
23
  kind: Kind;
23
- lane: string;
24
- col: number;
24
+ lane?: string;
25
+ col?: number;
25
26
  colSpan?: number;
26
- row: number;
27
+ row?: number;
28
+ x?: number;
29
+ y?: number;
30
+ w?: number;
31
+ h?: number;
27
32
  state: string;
28
33
  stateIn?: Record<string, string>;
29
34
  visibleIn?: string[];
@@ -34,6 +39,18 @@ export interface NodeDef {
34
39
  ref?: string;
35
40
  upgrade?: string;
36
41
  allowIsolated?: boolean;
42
+ contains?: string[];
43
+ collapsed?: boolean;
44
+ sla?: number;
45
+ metrics?: NodeMetric;
46
+ }
47
+ export interface NodeMetric {
48
+ value: number;
49
+ /** null means "no maximum" — the same as omitting it. Renderers treat both the same way. */
50
+ max?: number | null;
51
+ unit?: string;
52
+ label?: string;
53
+ series?: number[];
37
54
  }
38
55
  export interface LinkDef {
39
56
  id: string;
@@ -43,15 +60,18 @@ export interface LinkDef {
43
60
  stateIn?: Record<string, string>;
44
61
  visibleIn?: string[];
45
62
  label?: string;
63
+ kind?: "forward" | "loop-back";
46
64
  }
47
65
  export interface ViewDef { id: string; label: string; default?: boolean }
48
66
  export interface RelationDef { id: string; label: string; dir: "up" | "down"; filter?: { col?: number; kind?: Kind } }
49
67
  export interface ActionDef { id: string; label: string; dir: "down"; staleText: string }
50
68
  export interface NoteDef { anchor: string; title: string; text: string }
69
+ export interface StepDef { link: string; at?: number; dur?: number; caption?: string }
70
+ export interface ScenarioDef { id: string; label: string; steps: StepDef[] }
51
71
  export interface Model {
52
72
  version: 1;
53
73
  meta: { title: string; help: string; asOf: string; source: string; labels?: Record<string, string> };
54
- layout: "columns-lanes";
74
+ layout: "columns-lanes" | "free" | "auto";
55
75
  columns: ColumnDef[];
56
76
  lanes: LaneDef[];
57
77
  states: Record<string, StateDef>;
@@ -60,6 +80,7 @@ export interface Model {
60
80
  views?: ViewDef[];
61
81
  inspector?: { relations: RelationDef[]; actions?: ActionDef[] };
62
82
  notes?: NoteDef[];
83
+ scenarios?: ScenarioDef[];
63
84
  }
64
85
  export interface Issue { level: "error" | "warn"; code: string; where: string; message: string }
65
86
 
@@ -85,9 +106,9 @@ export function validateModel(input: unknown): Issue[] {
85
106
  }
86
107
  const m = input as Model;
87
108
  if (m.version !== 1) add("error", "model-version", "version", "version must be 1.");
88
- 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).`);
89
- if (!Array.isArray(m.columns) || m.columns.length === 0) add("error", "columns-missing", "columns", "At least one column is required.");
90
- if (!Array.isArray(m.lanes) || m.lanes.length === 0) add("error", "lanes-missing", "lanes", "At least one lane is required.");
109
+ 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".`);
110
+ 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.");
111
+ if (!Array.isArray(m.lanes) || (m.layout !== "auto" && m.lanes.length === 0)) add("error", "lanes-missing", "lanes", "At least one lane is required.");
91
112
  if (!m.states || typeof m.states !== "object" || Object.keys(m.states).length === 0) add("error", "states-missing", "states", "At least one state is required.");
92
113
  const columns = Array.isArray(m.columns) ? m.columns : [];
93
114
  const lanes = Array.isArray(m.lanes) ? m.lanes : [];
@@ -119,15 +140,36 @@ export function validateModel(input: unknown): Issue[] {
119
140
  const w = `nodes[${n.id}]`;
120
141
  if (nodeIds.has(n.id)) add("error", "node-duplicate", w, `Duplicate node id ${n.id}.`);
121
142
  nodeIds.add(n.id);
122
- if (!laneIds.has(n.lane)) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
143
+ if ((m.layout !== "auto" || n.lane !== undefined) && !laneIds.has(n.lane ?? "")) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
123
144
  for (const s of [n.state, ...Object.values(n.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
124
- 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).`);
145
+ 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).`);
125
146
  const span = n.colSpan ?? 1;
126
- 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}.`);
147
+ 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}.`);
148
+ if (m.layout === "free") {
149
+ if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) add("error", "position-invalid", w, `Node ${n.id} needs finite x and y.`);
150
+ if ((n.w !== undefined && (!Number.isFinite(n.w) || n.w <= 0)) || (n.h !== undefined && (!Number.isFinite(n.h) || n.h <= 0))) add("error", "size-invalid", w, `Node ${n.id} needs positive finite width and height.`);
151
+ }
152
+ const metric = n.metrics;
153
+ const invalidSeries = metric?.series !== undefined && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some(v => !Number.isFinite(v)));
154
+ if (metric !== undefined && (!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.");
155
+ if (n.sla !== undefined && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
127
156
  }
128
157
  for (const n of nodes) {
129
158
  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}).`);
130
159
  }
160
+ const owner = new Map<string, string>();
161
+ for (const group of nodes.filter(n => n.contains !== undefined)) {
162
+ if (group.kind !== "group" || !Array.isArray(group.contains)) {
163
+ add("error", "contains-shape", `nodes[${group.id}]`, "Only a group may contain a list of node ids.");
164
+ continue;
165
+ }
166
+ for (const id of group.contains) {
167
+ if (id === group.id) add("error", "contains-self", `nodes[${group.id}]`, `Group ${group.id} cannot contain itself.`);
168
+ else if (!nodeIds.has(id)) add("error", "contains-undefined", `nodes[${group.id}]`, `Contained node ${id} does not exist.`);
169
+ else if (owner.has(id)) add("error", "contains-duplicate", `nodes[${group.id}]`, `Node ${id} is already contained by ${owner.get(id)}.`);
170
+ else owner.set(id, group.id);
171
+ }
172
+ }
131
173
  const linkIds = new Set<string>();
132
174
  const linked = new Set<string>();
133
175
  for (const l of links) {
@@ -138,14 +180,48 @@ export function validateModel(input: unknown): Issue[] {
138
180
  else {
139
181
  linked.add(l.from); linked.add(l.to);
140
182
  const a = nodes.find(n => n.id === l.from)!, b = nodes.find(n => n.id === l.to)!;
141
- if (b.col <= a.col + (a.colSpan ?? 1) - 1) {
142
- 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.`);
183
+ if (m.layout !== "free" && a.col !== undefined && b.col !== undefined) {
184
+ const forward = b.col > a.col + (a.colSpan ?? 1) - 1;
185
+ 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.`);
186
+ if (forward && l.kind === "loop-back") add("warn", "loop-back-forward", w, `Link ${l.id} is marked loop-back but runs forward.`);
143
187
  }
144
188
  }
145
189
  for (const s of [l.state, ...Object.values(l.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
146
190
  }
191
+ const scenarioIds = new Set<string>();
192
+ for (const [i, scenario] of (Array.isArray(m.scenarios) ? m.scenarios : []).entries()) {
193
+ const w = `scenarios[${i}]`;
194
+ if (!scenario || typeof scenario.id !== "string" || typeof scenario.label !== "string" || !Array.isArray(scenario.steps)) { add("error", "scenario-shape", w, "A scenario needs an id, label and steps."); continue; }
195
+ if (scenarioIds.has(scenario.id)) add("error", "scenario-duplicate", w, `Duplicate scenario id ${scenario.id}.`);
196
+ scenarioIds.add(scenario.id);
197
+ if (!scenario.steps.length) add("error", "scenario-empty", w, "A scenario needs at least one step.");
198
+ const timed = scenario.steps.filter(s => s && s.at !== undefined).length;
199
+ if (timed && timed !== scenario.steps.length) add("error", "scenario-at-partial", w, "Give every step an at time or none.");
200
+ let prev: LinkDef | undefined, previousAt = -Infinity;
201
+ for (const [j, step] of scenario.steps.entries()) {
202
+ const sw = `${w}.steps[${j}]`;
203
+ const link = links.find(l => l.id === step?.link);
204
+ if (!link) add("error", "scenario-link", sw, `Unknown link ${step?.link}.`);
205
+ if (step?.at !== undefined) {
206
+ 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.");
207
+ if (Number.isFinite(step.at) && step.at >= 0 && step.at >= previousAt) previousAt = step.at;
208
+ }
209
+ if (step?.dur !== undefined && (!Number.isFinite(step.dur) || step.dur <= 0)) add("error", "scenario-duration", sw, "Duration must be positive and finite.");
210
+ if (prev && link) {
211
+ const previousTo = prev.to;
212
+ 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.");
213
+ }
214
+ if (link) prev = link;
215
+ }
216
+ }
217
+ for (const n of nodes) {
218
+ if (n.kind !== "gateway") continue;
219
+ const exits = links.filter(l => l.from === n.id);
220
+ if (exits.length === 0) add("warn", "gateway-no-exit", `nodes[${n.id}]`, `Gateway ${n.id} has no exit.`);
221
+ 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.`);
222
+ }
147
223
  for (const n of nodes) {
148
- if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note") {
224
+ if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note" && !(n.kind === "group" && n.contains?.length)) {
149
225
  add("error", "orphan", `nodes[${n.id}]`, `Node ${n.id} has no links. Add a link, a placeholder neighbour, or set allowIsolated.`);
150
226
  }
151
227
  }
@@ -153,9 +229,18 @@ export function validateModel(input: unknown): Issue[] {
153
229
  for (let i = 0; i < nodes.length; i++) {
154
230
  for (let j = i + 1; j < nodes.length; j++) {
155
231
  const a = nodes[i]!, b = nodes[j]!;
156
- const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
157
- if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
158
- add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
232
+ if (a.contains?.includes(b.id) || b.contains?.includes(a.id)) continue;
233
+ if (m.layout === "free") {
234
+ 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;
235
+ if ([a.x, a.y, b.x, b.y, aw, ah, bw, bh].every(v => typeof v === "number" && Number.isFinite(v))
236
+ && a.x! < b.x! + bw && b.x! < a.x! + aw && a.y! < b.y! + bh && b.y! < a.y! + ah) {
237
+ add("error", "rect-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap.`);
238
+ }
239
+ } else if (a.col !== undefined && b.col !== undefined && a.row !== undefined && b.row !== undefined) {
240
+ const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
241
+ if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
242
+ add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
243
+ }
159
244
  }
160
245
  }
161
246
  }
@@ -22,6 +22,14 @@ export function install(ctx: Ctx): void {
22
22
  const a = anchorOf(n.anchor);
23
23
  if (!a) continue;
24
24
  let r = a.getBoundingClientRect();
25
+ if (!r.width && !r.height) {
26
+ // A closed <details> hides the anchor, so the marker sits on the outermost closed details.
27
+ // An open details hides nothing: an anchor that is still zero-size inside one is hidden for
28
+ // another reason and gets no marker.
29
+ let d = a.closest<HTMLDetailsElement>("details");
30
+ while (d && d.open) d = d.parentElement?.closest<HTMLDetailsElement>("details") ?? null;
31
+ if (d) r = d.getBoundingClientRect();
32
+ }
25
33
  if (!r.width && !r.height) continue;
26
34
  if (a.id === "inspector" && !a.classList.contains("open")) {
27
35
  const vr = els.vp.getBoundingClientRect();
@@ -0,0 +1,21 @@
1
+ import { mount } from "./engine.ts";
2
+ import type { Model, NoteDef } from "./model.ts";
3
+
4
+ function auto(): void {
5
+ const m = document.getElementById("flow-model");
6
+ if (!m) return;
7
+ try {
8
+ const model = JSON.parse(m.textContent ?? "{}") as Model;
9
+ const n = document.getElementById("flow-notes");
10
+ const notes = n ? (JSON.parse(n.textContent ?? "[]") as NoteDef[]) : undefined;
11
+ mount(document, model, { notes });
12
+ } catch (e) {
13
+ console.error(e);
14
+ const h = document.getElementById("help");
15
+ if (h) h.textContent = "This page could not be drawn: " + (e instanceof Error ? e.message : String(e));
16
+ }
17
+ }
18
+
19
+ window.flowStudioMount = mount;
20
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", auto);
21
+ else auto();
@@ -0,0 +1,104 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { planScenario, positionAt, slaState, formatElapsed, exitsOf, suggestedExit, buildManualRoute, extendScenarioWithRoute } from "./playback.ts";
5
+ import { validateModel, type Model, type ScenarioDef, type StepDef } from "./model.ts";
6
+
7
+ const model = JSON.parse(readFileSync(new URL("../examples/order-desk.json", import.meta.url), "utf8")) as Model;
8
+ const steps = [{ link: "l-intake-verify" }, { link: "l-verify-route", dur: 400 }];
9
+ test("untimed playback is sequential and clamped", () => {
10
+ const p = planScenario(model, { id: "a", label: "A", steps });
11
+ assert.equal(p.mode, "none"); assert.equal(p.totalMs, 1300);
12
+ assert.deepEqual(p.steps.map(s => s.at), [0, 900]);
13
+ assert.deepEqual(positionAt(p, -10), { stepIndex: 0, progress: 0, done: false });
14
+ assert.deepEqual(positionAt(p, 900), { stepIndex: 1, progress: 0, done: false });
15
+ assert.deepEqual(positionAt(p, Infinity), { stepIndex: 1, progress: 1, done: true });
16
+ });
17
+ test("elapsed playback scales the shortest gap and handles empty plans", () => {
18
+ const p = planScenario(model, { id: "a", label: "A", steps: [{ link: "l-intake-verify", at: 2 }, { link: "l-verify-route", at: 2.2 }] });
19
+ assert.equal(p.mode, "elapsed"); assert.equal(p.steps[1]?.at, 900);
20
+ assert.equal(p.steps[0]?.caption, "New request to Verify details");
21
+ const empty = planScenario(model, { id: "x", label: "X", steps: [] });
22
+ assert.deepEqual(positionAt(empty, 0), { stepIndex: -1, progress: 0, done: true });
23
+ const one = planScenario(model, { id: "one", label: "One", steps: [{ link: "l-intake-verify", at: 0 }] });
24
+ assert.equal(one.totalMs, 900);
25
+ assert.deepEqual(positionAt(one, NaN), { stepIndex: 0, progress: 0, done: false });
26
+ assert.deepEqual(positionAt(one, 900), { stepIndex: 0, progress: 1, done: true });
27
+ });
28
+ test("time and branches have stable boundaries", () => {
29
+ assert.equal(slaState(79.9, 100), "ok"); assert.equal(slaState(80, 100), "warn"); assert.equal(slaState(100, 100), "breach");
30
+ assert.equal(formatElapsed(59.6), "1:00"); assert.equal(formatElapsed(3600), "1:00:00");
31
+ assert.deepEqual(exitsOf(model, "route").map(l => l.id), ["l-route-hold", "l-route-quote"]);
32
+ assert.equal(suggestedExit(exitsOf(model, "route"), "l-route-quote")?.id, "l-route-quote");
33
+ });
34
+ test("validator reports scenario and metric errors", () => {
35
+ const m = structuredClone(model);
36
+ m.scenarios = [{ id: "a", label: "A", steps: [{ link: "missing", at: 2 }, { link: "l-intake-verify" }] }, { id: "a", label: "B", steps: [] }];
37
+ m.nodes[0]!.metrics = { value: NaN, series: Array(61).fill(0) };
38
+ const codes = validateModel(m).map(i => i.code);
39
+ for (const c of ["scenario-duplicate", "scenario-empty", "scenario-link", "scenario-at-partial", "metric-invalid"]) assert.ok(codes.includes(c), c);
40
+ });
41
+ test("validator warns for disconnected steps and rejects decreasing time", () => {
42
+ const m = structuredClone(model);
43
+ m.scenarios = [{ id: "gap", label: "Gap", steps: [{ link: "l-intake-verify", at: 10 }, { link: "l-route-quote", at: 9 }] }];
44
+ const codes = validateModel(m).map(i => i.code);
45
+ assert.ok(codes.includes("scenario-at-order"));
46
+ assert.ok(codes.includes("scenario-gap"));
47
+ });
48
+ test("validator catches decreasing time even when intermediate step has NaN at", () => {
49
+ const m = structuredClone(model);
50
+ m.scenarios = [{
51
+ id: "nan-order", label: "NaN Order",
52
+ steps: [
53
+ { link: "l-intake-verify", at: 10 },
54
+ { link: "l-verify-route", at: NaN },
55
+ { link: "l-route-quote", at: 5 },
56
+ ],
57
+ }];
58
+ const issues = validateModel(m).filter(i => i.code === "scenario-at-order");
59
+ assert.equal(issues.length, 2, "Both step with NaN and step with decreasing time should report scenario-at-order");
60
+ });
61
+ test("buildManualRoute and extendScenarioWithRoute preserve manual branches and elapsed timing", () => {
62
+ const choiceLink = model.links.find(l => l.id === "l-route-hold")!;
63
+ const route = buildManualRoute(model, choiceLink);
64
+ assert.deepEqual(route.map(l => l.id), ["l-route-hold", "l-hold-verify", "l-verify-route"]);
65
+
66
+ const initialScenario = model.scenarios?.[0]!;
67
+ const plan1 = planScenario(model, initialScenario);
68
+ // User at step 1 (gateway 'route') chooses 'l-route-hold'
69
+ const manual1 = extendScenarioWithRoute(initialScenario, plan1, 1, route);
70
+ assert.equal(manual1.steps.length, 5);
71
+ assert.equal(manual1.steps[2].link, "l-route-hold");
72
+ assert.equal(manual1.steps[3].link, "l-hold-verify");
73
+ assert.equal(manual1.steps[4].link, "l-verify-route");
74
+ assert.ok(manual1.steps[2].at! > manual1.steps[1].at!);
75
+ assert.ok(manual1.steps[3].at! > manual1.steps[2].at!);
76
+ assert.ok(manual1.steps[4].at! > manual1.steps[3].at!);
77
+
78
+ const plan2 = planScenario(model, manual1);
79
+ assert.equal(plan2.mode, "elapsed");
80
+
81
+ // Subsequent manual choice from the new branch must NOT drop earlier manual steps
82
+ const route2 = [model.links.find(l => l.id === "l-route-quote")!];
83
+ const manual2 = extendScenarioWithRoute(manual1, plan2, 4, route2);
84
+ assert.equal(manual2.steps.length, 6);
85
+ assert.deepEqual(manual2.steps.map(s => s.link), [
86
+ initialScenario.steps[0].link,
87
+ initialScenario.steps[1].link,
88
+ "l-route-hold",
89
+ "l-hold-verify",
90
+ "l-verify-route",
91
+ "l-route-quote",
92
+ ]);
93
+ assert.ok(manual2.steps[5].at! > manual2.steps[4].at!);
94
+ });
95
+ test("a scenario with malformed steps plans without throwing and keeps indices aligned", () => {
96
+ // The validator reports a null step (scenario-link) but the page still renders, so playback must not throw on it.
97
+ const sc = { id: "n", label: "N", steps: [{ link: "l-intake-verify", at: 0 }, null as unknown as StepDef, { link: "l-verify-route", at: 5 }] } as unknown as ScenarioDef;
98
+ const plan = planScenario(model, sc);
99
+ assert.deepEqual(plan.steps.map(s => [s.link, s.index]), [["l-intake-verify", 0], ["l-verify-route", 2]]);
100
+ // The counter reads a scenario step's at through the plan step's index, so the index must stay the original one.
101
+ assert.equal(sc.steps[2]!.at, 5);
102
+ const ext = extendScenarioWithRoute(sc, plan, 1, [model.links.find(l => l.id === "l-route-hold")!]);
103
+ assert.doesNotThrow(() => planScenario(model, ext));
104
+ });
@@ -0,0 +1,106 @@
1
+ import type { LinkDef, Model, ScenarioDef, StepDef } from "./model.ts";
2
+
3
+ export interface PlannedStep { link: string; from: string; to: string; at: number; dur: number; caption: string; index: number }
4
+ export interface Plan { steps: PlannedStep[]; mode: "elapsed" | "none"; totalMs: number }
5
+
6
+ export function planScenario(model: Model, scenario: ScenarioDef): Plan {
7
+ const source = scenario.steps ?? [];
8
+ const mode = source.some(s => s?.at !== undefined) ? "elapsed" : "none";
9
+ const start = source[0]?.at ?? 0;
10
+ // One global scale keeps the relative spacing. Zero gaps cannot be scaled, so they follow the prior duration.
11
+ const scale = mode === "elapsed" ? Math.max(1, ...source.slice(1).map((s, i) => {
12
+ const gap = ((s?.at ?? 0) - (source[i]?.at ?? 0)) * 1000;
13
+ return gap > 0 ? (source[i]?.dur ?? 900) / gap : 1;
14
+ })) : 1;
15
+ const steps: PlannedStep[] = [];
16
+ for (let i = 0; i < source.length; i++) {
17
+ const s = source[i];
18
+ // A step that is not an object is a validator error (scenario-link), but the page still renders:
19
+ // skip it and keep the original index, because the counter reads a step's at back through it.
20
+ if (!s || typeof s !== "object") continue;
21
+ const link = model.links.find(l => l.id === s.link);
22
+ if (!link) continue;
23
+ const from = model.nodes.find(n => n.id === link.from);
24
+ const to = model.nodes.find(n => n.id === link.to);
25
+ const dur = Number.isFinite(s.dur) && (s.dur ?? 0) > 0 ? s.dur! : 900;
26
+ const proposed = mode === "elapsed" ? Math.max(0, ((s.at ?? 0) - start) * 1000 * scale) : 0;
27
+ const at = steps.length === 0 ? 0 : Math.max(proposed, steps.at(-1)!.at + steps.at(-1)!.dur);
28
+ 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 });
29
+ }
30
+ return { steps, mode, totalMs: steps.length ? steps.at(-1)!.at + steps.at(-1)!.dur : 0 };
31
+ }
32
+
33
+ export function positionAt(plan: Plan, tMs: number): { stepIndex: number; progress: number; done: boolean } {
34
+ if (!plan.steps.length) return { stepIndex: -1, progress: 0, done: true };
35
+ const t = Number.isNaN(tMs) ? 0 : Math.max(0, tMs);
36
+ if (t >= plan.totalMs) return { stepIndex: plan.steps.length - 1, progress: 1, done: true };
37
+ let i = plan.steps.length - 1;
38
+ while (i > 0 && t < plan.steps[i]!.at) i--;
39
+ const s = plan.steps[i]!;
40
+ return { stepIndex: i, progress: Math.max(0, Math.min(1, (t - s.at) / s.dur)), done: false };
41
+ }
42
+
43
+ export function slaState(elapsedSec: number, sla: number): "ok" | "warn" | "breach" {
44
+ if (elapsedSec >= sla) return "breach";
45
+ return elapsedSec >= sla * .8 ? "warn" : "ok";
46
+ }
47
+
48
+ export function formatElapsed(sec: number): string {
49
+ const n = Math.max(0, Math.round(Number.isFinite(sec) ? sec : 0));
50
+ const m = Math.floor(n / 60), s = String(n % 60).padStart(2, "0");
51
+ return n < 3600 ? `${m}:${s}` : `${Math.floor(n / 3600)}:${String(m % 60).padStart(2, "0")}:${s}`;
52
+ }
53
+
54
+ export function exitsOf(model: Model, nodeId: string): LinkDef[] {
55
+ const canon = (id: string) => model.nodes.find(n => n.id === id)?.ref ?? id;
56
+ return model.links.filter(l => canon(l.from) === canon(nodeId));
57
+ }
58
+
59
+ export function suggestedExit(exits: LinkDef[], nextLink: string | undefined): LinkDef | undefined {
60
+ return exits.find(l => l.id === nextLink);
61
+ }
62
+
63
+ export function buildManualRoute(model: Model, choiceLink: LinkDef): LinkDef[] {
64
+ const route = [choiceLink];
65
+ const seen = new Set([choiceLink.id]);
66
+ let at = choiceLink.to;
67
+ while (exitsOf(model, at).length === 1) {
68
+ const next = exitsOf(model, at)[0]!;
69
+ if (seen.has(next.id)) break;
70
+ route.push(next);
71
+ seen.add(next.id);
72
+ at = next.to;
73
+ }
74
+ return route;
75
+ }
76
+
77
+ export function extendScenarioWithRoute(
78
+ scenario: ScenarioDef,
79
+ plan: Plan,
80
+ stepIndex: number,
81
+ route: LinkDef[]
82
+ ): ScenarioDef {
83
+ const isElapsed = plan.mode === "elapsed" || scenario.steps.some(s => s?.at !== undefined);
84
+ const prefix: StepDef[] = scenario.steps.slice(0, stepIndex + 1).map(s => ({ ...s }));
85
+ for (let i = prefix.length; i <= stepIndex && i < plan.steps.length; i++) {
86
+ const ps = plan.steps[i]!;
87
+ prefix.push({ link: ps.link, dur: ps.dur, caption: ps.caption });
88
+ }
89
+ let lastAt = 0;
90
+ if (isElapsed) {
91
+ const lastWithAt = [...prefix].reverse().find(s => s?.at !== undefined);
92
+ lastAt = lastWithAt?.at ?? 0;
93
+ }
94
+ const newSteps: StepDef[] = route.map(l => {
95
+ const step: StepDef = { link: l.id };
96
+ if (isElapsed) {
97
+ lastAt += 1;
98
+ step.at = lastAt;
99
+ }
100
+ return step;
101
+ });
102
+ return {
103
+ ...scenario,
104
+ steps: [...prefix, ...newSteps],
105
+ };
106
+ }