@caddis/cli 0.4.44 → 0.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  3. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  4. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  57. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  60. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  92. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  93. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
  97. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
  98. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  99. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  100. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
  101. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
  102. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
  103. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  104. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  105. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  106. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
  192. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
  193. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  194. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  195. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
  196. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
  197. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
  198. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
  199. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
  200. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  201. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  202. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
  203. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
  204. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
  205. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  206. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  207. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  208. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  209. package/bundles/manifest.json +2 -2
  210. package/package.json +1 -1
@@ -0,0 +1,250 @@
1
+ // The model: what an author writes. Pure types plus validation. No DOM.
2
+
3
+ export type Kind = "card" | "group" | "chip" | "gateway" | "start" | "end" | "event" | "placeholder" | "note";
4
+ export type Tone = "ok" | "warn" | "crit" | "info" | "muted" | "accent";
5
+ export type Border = "solid" | "dashed" | "dotted";
6
+ export type EdgeStyle = "flow" | "dash" | "dash-short" | "dot";
7
+ export type Motion = "flow" | "flow-slow" | "breathe" | "none";
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 };
10
+
11
+ export interface StateDef {
12
+ word: string;
13
+ tone: Tone;
14
+ border: Border;
15
+ edge: EdgeStyle;
16
+ motion: Motion;
17
+ bucket: Bucket;
18
+ }
19
+ export interface ColumnDef { id: string; title: string; short?: string; width: number; phase?: string }
20
+ export interface LaneDef { id: string; title: string; badge?: string; badgeTone?: "ok" | "warn" | "muted"; note?: string; gap?: boolean }
21
+ export interface NodeDef {
22
+ id: string;
23
+ kind: Kind;
24
+ lane?: string;
25
+ col?: number;
26
+ colSpan?: number;
27
+ row?: number;
28
+ x?: number;
29
+ y?: number;
30
+ w?: number;
31
+ h?: number;
32
+ state: string;
33
+ stateIn?: Record<string, string>;
34
+ visibleIn?: string[];
35
+ title: string;
36
+ subtitle?: string;
37
+ members?: string[];
38
+ weight?: number;
39
+ ref?: string;
40
+ upgrade?: string;
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[];
54
+ }
55
+ export interface LinkDef {
56
+ id: string;
57
+ from: string;
58
+ to: string;
59
+ state: string;
60
+ stateIn?: Record<string, string>;
61
+ visibleIn?: string[];
62
+ label?: string;
63
+ kind?: "forward" | "loop-back";
64
+ }
65
+ export interface ViewDef { id: string; label: string; default?: boolean }
66
+ export interface RelationDef { id: string; label: string; dir: "up" | "down"; filter?: { col?: number; kind?: Kind } }
67
+ export interface ActionDef { id: string; label: string; dir: "down"; staleText: string }
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[] }
71
+ export interface Model {
72
+ version: 1;
73
+ meta: { title: string; help: string; asOf: string; source: string; labels?: Record<string, string> };
74
+ layout: "columns-lanes" | "free" | "auto";
75
+ columns: ColumnDef[];
76
+ lanes: LaneDef[];
77
+ states: Record<string, StateDef>;
78
+ nodes: NodeDef[];
79
+ links: LinkDef[];
80
+ views?: ViewDef[];
81
+ inspector?: { relations: RelationDef[]; actions?: ActionDef[] };
82
+ notes?: NoteDef[];
83
+ scenarios?: ScenarioDef[];
84
+ }
85
+ export interface Issue { level: "error" | "warn"; code: string; where: string; message: string }
86
+
87
+ const TONES = ["ok", "warn", "crit", "info", "muted", "accent"];
88
+ const BORDERS = ["solid", "dashed", "dotted"];
89
+ const EDGES = ["flow", "dash", "dash-short", "dot"];
90
+ const MOTIONS = ["flow", "flow-slow", "breathe", "none"];
91
+ const BUCKETS = ["good", "neutral", "gap"];
92
+
93
+ /** Turns a value into text that is safe inside HTML. Use it for every model string put into markup. */
94
+ export function esc(s: unknown): string {
95
+ const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
96
+ return String(s ?? "").replace(/[&<>"']/g, c => map[c] as string);
97
+ }
98
+
99
+ /** Checks a model and returns every problem. Never throws. An error blocks the build; a warning does not. */
100
+ export function validateModel(input: unknown): Issue[] {
101
+ const out: Issue[] = [];
102
+ const add = (level: Issue["level"], code: string, where: string, message: string) => out.push({ level, code, where, message });
103
+ if (!input || typeof input !== "object") {
104
+ add("error", "model-shape", "model", "The model must be a JSON object.");
105
+ return out;
106
+ }
107
+ const m = input as Model;
108
+ if (m.version !== 1) add("error", "model-version", "version", "version must be 1.");
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.");
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.");
113
+ const columns = Array.isArray(m.columns) ? m.columns : [];
114
+ const lanes = Array.isArray(m.lanes) ? m.lanes : [];
115
+ const states = m.states && typeof m.states === "object" ? m.states : {};
116
+ if (!m.meta || typeof m.meta !== "object" || typeof m.meta.title !== "string") add("error", "meta-missing", "meta", "meta with a title is required.");
117
+ // Entries that are not objects are reported and dropped, so no later loop can throw on them.
118
+ const nodes: NodeDef[] = [];
119
+ (Array.isArray(m.nodes) ? m.nodes : []).forEach((n, i) => {
120
+ if (!n || typeof n !== "object" || typeof (n as NodeDef).id !== "string") add("error", "node-shape", `nodes[${i}]`, "A node must be an object with a string id.");
121
+ else nodes.push(n);
122
+ });
123
+ const links: LinkDef[] = [];
124
+ (Array.isArray(m.links) ? m.links : []).forEach((l, i) => {
125
+ if (!l || typeof l !== "object" || typeof (l as LinkDef).id !== "string") add("error", "link-shape", `links[${i}]`, "A link must be an object with a string id.");
126
+ else links.push(l);
127
+ });
128
+ if (nodes.length === 0) add("warn", "model-empty", "nodes", "The model has no nodes.");
129
+
130
+ for (const [id, s] of Object.entries(states)) {
131
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) add("error", "state-id", `states[${id}]`, `State id ${id} may only use letters, digits, "_" and "-" (it becomes a CSS class).`);
132
+ const ok = s && typeof s.word === "string" && TONES.includes(s.tone) && BORDERS.includes(s.border) && EDGES.includes(s.edge) && MOTIONS.includes(s.motion) && BUCKETS.includes(s.bucket);
133
+ if (!ok) add("error", "state-shape", `states[${id}]`, `State ${id} needs word, tone, border, edge, motion and bucket with allowed values.`);
134
+ }
135
+ const laneIds = new Set(lanes.map(l => l.id));
136
+ const stateIds = new Set(Object.keys(states));
137
+ const nodeIds = new Set<string>();
138
+ const ncols = columns.length;
139
+ for (const n of nodes) {
140
+ const w = `nodes[${n.id}]`;
141
+ if (nodeIds.has(n.id)) add("error", "node-duplicate", w, `Duplicate node id ${n.id}.`);
142
+ nodeIds.add(n.id);
143
+ if ((m.layout !== "auto" || n.lane !== undefined) && !laneIds.has(n.lane ?? "")) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
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.`);
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).`);
146
+ const span = n.colSpan ?? 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.");
156
+ }
157
+ for (const n of nodes) {
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}).`);
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
+ }
173
+ const linkIds = new Set<string>();
174
+ const linked = new Set<string>();
175
+ for (const l of links) {
176
+ const w = `links[${l.id}]`;
177
+ if (linkIds.has(l.id)) add("error", "link-duplicate", w, `Duplicate link id ${l.id}.`);
178
+ linkIds.add(l.id);
179
+ if (!nodeIds.has(l.from) || !nodeIds.has(l.to)) add("error", "link-endpoint", w, `Link ${l.id} has an endpoint that is not a node (${l.from} to ${l.to}).`);
180
+ else {
181
+ linked.add(l.from); linked.add(l.to);
182
+ const a = nodes.find(n => n.id === l.from)!, b = nodes.find(n => n.id === l.to)!;
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.`);
187
+ }
188
+ }
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.`);
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
+ }
223
+ for (const n of nodes) {
224
+ if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note" && !(n.kind === "group" && n.contains?.length)) {
225
+ add("error", "orphan", `nodes[${n.id}]`, `Node ${n.id} has no links. Add a link, a placeholder neighbour, or set allowIsolated.`);
226
+ }
227
+ }
228
+ // Columns and rows are global to the canvas, so two nodes clash whatever their lane.
229
+ for (let i = 0; i < nodes.length; i++) {
230
+ for (let j = i + 1; j < nodes.length; j++) {
231
+ const a = nodes[i]!, b = nodes[j]!;
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
+ }
244
+ }
245
+ }
246
+ }
247
+ const used = new Set(nodes.map(n => n.lane));
248
+ for (const l of lanes) if (!used.has(l.id)) add("warn", "lane-empty", `lanes[${l.id}]`, `Lane ${l.id} has no nodes.`);
249
+ return out;
250
+ }
@@ -0,0 +1,100 @@
1
+ // Implementation notes (a left drawer with numbered markers on the page) and the model reference
2
+ // section with Copy as Markdown and Copy JSON. The notes are for the person or agent who builds the page.
3
+
4
+ import { el, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+
7
+ export function install(ctx: Ctx): void {
8
+ const { els, model } = ctx;
9
+ const notes = ctx.notes.map((n, i) => ({ id: i + 1, ...n }));
10
+
11
+ const card = (id: number) => els.notelist.querySelector<HTMLElement>(`[data-n="${id}"]`);
12
+ const anchorOf = (sel: string): Element | null => { try { return document.querySelector(sel); } catch { return null; } };
13
+
14
+ function clearMarkers(): void {
15
+ document.querySelectorAll(".marker").forEach(m => m.remove());
16
+ document.querySelectorAll(".anchored").forEach(a => a.classList.remove("anchored"));
17
+ }
18
+
19
+ function placeMarkers(): void {
20
+ clearMarkers();
21
+ for (const n of notes) {
22
+ const a = anchorOf(n.anchor);
23
+ if (!a) continue;
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
+ }
33
+ if (!r.width && !r.height) continue;
34
+ if (a.id === "inspector" && !a.classList.contains("open")) {
35
+ const vr = els.vp.getBoundingClientRect();
36
+ r = { left: vr.right - 130, top: vr.top + 56, right: vr.right - 128, bottom: vr.top + 58, width: 2, height: 2, x: 0, y: 0, toJSON: () => ({}) };
37
+ }
38
+ const v = els.vp.getBoundingClientRect();
39
+ if (a.closest("#world") && (r.right < v.left || r.left > v.right || r.bottom < v.top || r.top > v.bottom)) continue;
40
+ const m = el("button", "marker", String(n.id));
41
+ m.type = "button"; m.dataset.n = String(n.id); m.title = n.title;
42
+ m.setAttribute("aria-label", `Note ${n.id}: ${n.title}`);
43
+ m.style.left = r.left + scrollX - 10 + "px";
44
+ m.style.top = r.top + scrollY - 10 + "px";
45
+ m.addEventListener("mouseenter", () => { a.classList.add("anchored"); card(n.id)?.classList.add("on"); });
46
+ m.addEventListener("mouseleave", () => { a.classList.remove("anchored"); card(n.id)?.classList.remove("on"); });
47
+ m.addEventListener("click", () => card(n.id)?.scrollIntoView({ block: "center", behavior: ctx.reduced ? "auto" : "smooth" }));
48
+ document.body.appendChild(m);
49
+ }
50
+ }
51
+
52
+ function toggleNotes(next?: boolean): void {
53
+ const on_ = next === undefined ? !document.body.classList.contains("notes") : next;
54
+ document.body.classList.toggle("notes", on_);
55
+ els.notesBtn.setAttribute("aria-pressed", String(on_));
56
+ if (on_) window.setTimeout(placeMarkers, ctx.reduced ? 30 : 340);
57
+ else clearMarkers();
58
+ }
59
+
60
+ els.notelist.innerHTML = notes.map(n => `<div class="ncard" data-n="${n.id}"><b><span>${n.id}</span>${esc(n.title)}</b>${esc(n.text)}</div>`).join("")
61
+ || '<p class="note">This page has no implementation notes.</p>';
62
+ els.notelist.querySelectorAll<HTMLElement>(".ncard").forEach(c => {
63
+ c.addEventListener("mouseenter", () => { const n = notes.find(x => String(x.id) === c.dataset.n); const a = n && anchorOf(n.anchor); if (a) a.classList.add("anchored"); });
64
+ c.addEventListener("mouseleave", () => document.querySelectorAll(".anchored").forEach(a => a.classList.remove("anchored")));
65
+ });
66
+ on(ctx, els.notesBtn, "click", () => toggleNotes());
67
+ on(ctx, window, "scroll", () => { if (document.body.classList.contains("notes")) placeMarkers(); }, { passive: true });
68
+
69
+ // ---- model reference
70
+ const markdown = (): string => {
71
+ const out = [`# ${model.meta.title}`, "", model.meta.help, "", `As of ${model.meta.asOf}. Source: ${model.meta.source}`, "", "## States"];
72
+ for (const [id, s] of Object.entries(model.states)) out.push(`- **${s.word}** (${id}): tone ${s.tone}, border ${s.border}, link ${s.edge}, motion ${s.motion}, bucket ${s.bucket}`);
73
+ out.push("", "## Columns", ...model.columns.map((c, i) => `${i + 1}. ${c.title}${c.phase ? ` (${c.phase})` : ""}`));
74
+ out.push("", "## Lanes", ...model.lanes.map(l => `- ${l.title}${l.badge ? ` [${l.badge}]` : ""}${l.note ? `: ${l.note}` : ""}`));
75
+ out.push("", `## Size`, `${model.nodes.length} nodes, ${model.links.length} links.`);
76
+ if (notes.length) out.push("", "## Notes", ...notes.map(n => `${n.id}. **${n.title}**: ${n.text}`));
77
+ return out.join("\n");
78
+ };
79
+ const rows = (items: string[]) => `<ul>${items.map(i => `<li>${i}</li>`).join("")}</ul>`;
80
+ els.specbody.innerHTML = `<div class="specbtns"><button type="button" id="cpMd">Copy as Markdown</button><button type="button" id="cpJson">Copy model JSON</button><span class="note" id="cpMsg" style="margin:0"></span></div>
81
+ <h3>States</h3>${rows(Object.entries(model.states).map(([id, s]) => `<b>${esc(s.word)}</b> (<code>${esc(id)}</code>): tone ${esc(s.tone)}, border ${esc(s.border)}, link ${esc(s.edge)}, motion ${esc(s.motion)}, bucket ${esc(s.bucket)}`))}
82
+ <h3>Columns</h3>${rows(model.columns.map(c => `${esc(c.title)}${c.phase ? ` (${esc(c.phase)})` : ""}`))}
83
+ <h3>Lanes</h3>${rows(model.lanes.map(l => `${esc(l.title)}${l.badge ? ` [${esc(l.badge)}]` : ""}${l.note ? `: ${esc(l.note)}` : ""}`))}
84
+ <h3>Size</h3><p>${model.nodes.length} nodes, ${model.links.length} links.</p>`;
85
+ const msg = (t: string) => { const m = document.getElementById("cpMsg"); if (m) m.textContent = t; };
86
+ const copy = async (text: string, what: string) => {
87
+ try { await navigator.clipboard.writeText(text); msg(`${what} copied.`); }
88
+ catch {
89
+ const ta = document.createElement("textarea");
90
+ ta.value = text; document.body.appendChild(ta); ta.select();
91
+ try { document.execCommand("copy"); msg(`${what} copied.`); } catch { msg("Copy is blocked here: select the text instead."); }
92
+ ta.remove();
93
+ }
94
+ };
95
+ document.getElementById("cpMd")?.addEventListener("click", () => void copy(markdown(), "Markdown"));
96
+ document.getElementById("cpJson")?.addEventListener("click", () => void copy(JSON.stringify(model, null, 2), "JSON"));
97
+
98
+ ctx.fn.toggleNotes = toggleNotes;
99
+ ctx.fn.placeMarkers = placeMarkers;
100
+ }
@@ -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
+ }