@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,144 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeGeometry } from "./layout.ts";
4
+ import type { Model } from "./model.ts";
5
+
6
+ const m = (): Model => ({
7
+ version: 1,
8
+ meta: { title: "t", help: "", asOf: "", source: "" },
9
+ layout: "columns-lanes",
10
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 200 }],
11
+ lanes: [{ id: "l1", title: "One" }, { id: "l2", title: "Two" }],
12
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
13
+ nodes: [
14
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
15
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "ok", title: "B" },
16
+ { id: "c", kind: "card", lane: "l2", col: 0, row: 1.8, state: "ok", title: "C" },
17
+ { id: "w", kind: "placeholder", lane: "l2", col: 0, colSpan: 2, row: 2.8, state: "ok", title: "W" },
18
+ ],
19
+ links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
20
+ });
21
+
22
+ test("columns are laid out left to right with the gap", () => {
23
+ const g = computeGeometry(m());
24
+ assert.equal(g.xs[0], 24);
25
+ assert.equal(g.xs[1], 24 + 150 + 34);
26
+ });
27
+ test("a node sits at its column and its row", () => {
28
+ const g = computeGeometry(m());
29
+ assert.equal(g.nodes.a!.x, 24);
30
+ assert.equal(g.nodes.a!.w, 150);
31
+ assert.ok(g.nodes.c!.y > g.nodes.a!.y);
32
+ });
33
+ test("a wide node spans its columns", () => {
34
+ const g = computeGeometry(m());
35
+ assert.equal(g.nodes.w!.w, 150 + 34 + 200);
36
+ });
37
+ test("a chip is shorter than a card", () => {
38
+ const mm = m();
39
+ mm.nodes.push({ id: "k", kind: "chip", lane: "l1", col: 0, row: 3.8, state: "ok", title: "k", ref: "a" });
40
+ const g = computeGeometry(mm);
41
+ assert.ok(g.nodes.k!.h < g.nodes.a!.h);
42
+ });
43
+ test("lane rectangles are computed from their nodes and do not overlap", () => {
44
+ const g = computeGeometry(m());
45
+ const a = g.lanes.l1!, b = g.lanes.l2!;
46
+ assert.ok(a.y + a.h <= b.y, `${a.y + a.h} <= ${b.y}`);
47
+ });
48
+ test("a lane without visible nodes has no rectangle", () => {
49
+ const g = computeGeometry(m(), { visible: id => id !== "c" && id !== "w" });
50
+ assert.equal(g.lanes.l2, undefined);
51
+ });
52
+ test("bounds cover every lane and the world is finite", () => {
53
+ const g = computeGeometry(m());
54
+ assert.ok(Object.values(g.lanes).every(l => l.y >= g.bounds.y && l.y + l.h <= g.bounds.y + g.bounds.h + 1e-6));
55
+ assert.ok(Number.isFinite(g.world.w) && Number.isFinite(g.world.h));
56
+ });
57
+ test("an empty model has a finite, empty geometry (review focus 2)", () => {
58
+ const mm = m();
59
+ mm.nodes = [];
60
+ mm.links = [];
61
+ const g = computeGeometry(mm);
62
+ assert.ok(Number.isFinite(g.bounds.w) && Number.isFinite(g.bounds.h) && Number.isFinite(g.world.h));
63
+ });
64
+ test("a node whose column is out of range does not produce NaN", () => {
65
+ const mm = m();
66
+ mm.nodes[0]!.col = 9;
67
+ const g = computeGeometry(mm);
68
+ assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
69
+ });
70
+ test("a gateway has a 64 px diamond box", () => {
71
+ const mm = m(); mm.nodes[0]!.kind = "gateway";
72
+ assert.equal(computeGeometry(mm).nodes.a!.h, 64);
73
+ });
74
+ test("loop-back channels stay below nodes, inside their lane, and separate from the next lane", () => {
75
+ const mm = m();
76
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
77
+ mm.links.push({ id: "again", from: "b", to: "a", state: "ok", kind: "loop-back" });
78
+ const g = computeGeometry(mm);
79
+ const y = g.loops.return!.channelY;
80
+ assert.ok(y >= g.nodes.a!.y + g.nodes.a!.h + 10);
81
+ assert.ok(y >= g.nodes.b!.y + g.nodes.b!.h + 10);
82
+ assert.equal(g.loops.again!.channelY, y + 8);
83
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h > g.loops.again!.channelY);
84
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
85
+ });
86
+
87
+ test("a loop clears a lower node within its horizontal span", () => {
88
+ const mm = m();
89
+ mm.columns.push({ id: "c2", title: "C", width: 150 });
90
+ mm.nodes.find(n => n.id === "b")!.col = 2;
91
+ mm.nodes.push({ id: "middle", kind: "card", lane: "l1", col: 1, row: 1, state: "ok", title: "Middle" });
92
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
93
+ const g = computeGeometry(mm);
94
+ assert.equal(g.loops.return!.channelY, g.nodes.middle!.y + g.nodes.middle!.h + 10);
95
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
96
+ });
97
+
98
+ test("a lower node outside the loop span does not lower its channel", () => {
99
+ const mm = m();
100
+ mm.columns.push({ id: "c2", title: "C", width: 150 });
101
+ mm.nodes.push({ id: "outside", kind: "card", lane: "l1", col: 2, row: 1, state: "ok", title: "Outside" });
102
+ mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
103
+ const g = computeGeometry(mm);
104
+ assert.equal(g.loops.return!.channelY, g.nodes.a!.y + g.nodes.a!.h + 10);
105
+ });
106
+
107
+ test("interleaved lanes calculate channels after all endpoint shifts", () => {
108
+ const mm = m();
109
+ mm.nodes = [
110
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
111
+ { id: "deep", kind: "card", lane: "l1", col: 1, row: 5, state: "ok", title: "Deep" },
112
+ { id: "b", kind: "card", lane: "l2", col: 0, row: 1, state: "ok", title: "B" },
113
+ ];
114
+ mm.links = [{ id: "return", from: "deep", to: "b", state: "ok", kind: "loop-back" }];
115
+ const g = computeGeometry(mm);
116
+ assert.ok(g.loops.return!.channelY >= g.nodes.b!.y + g.nodes.b!.h + 10);
117
+ assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
118
+ assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
119
+ assert.ok(Object.values(g.loops).every(l => Number.isFinite(l.channelY)));
120
+ });
121
+
122
+ test("free layout uses explicit world rectangles and works without columns", () => {
123
+ const mm = m();
124
+ mm.layout = "free"; mm.columns = [];
125
+ mm.nodes = [
126
+ { id: "a", kind: "card", lane: "l1", x: 18, y: 70, w: 121, h: 47, state: "ok", title: "A" },
127
+ { id: "b", kind: "card", lane: "l2", x: 250, y: 300, state: "ok", title: "B" },
128
+ ];
129
+ const g = computeGeometry(mm);
130
+ assert.deepEqual(g.xs, []);
131
+ assert.deepEqual(g.nodes.a, { id: "a", x: 18, y: 70, w: 121, h: 47 });
132
+ assert.equal(g.nodes.b?.w, 190);
133
+ assert.ok(g.world.w >= 440);
134
+ assert.ok(g.lanes.l1 && g.lanes.l2);
135
+ });
136
+
137
+ test("free layout preserves finite negative node coordinates", () => {
138
+ const mm = m(); mm.layout = "free"; mm.columns = [];
139
+ mm.nodes = [{ id: "a", kind: "card", lane: "l1", x: -80, y: -40, w: 60, h: 30, state: "ok", title: "A" }];
140
+ const g = computeGeometry(mm);
141
+ assert.equal(g.nodes.a!.x, -80);
142
+ assert.equal(g.nodes.a!.y, -40);
143
+ assert.ok([g.bounds.x, g.bounds.y, g.bounds.w, g.bounds.h, g.world.w, g.world.h].every(Number.isFinite));
144
+ });
@@ -0,0 +1,95 @@
1
+ // Geometry from a model: where every node, lane and column sits in world units. Pure, no DOM.
2
+ // Node sizes are fixed per kind, so layout never depends on fonts or text measurement.
3
+
4
+ import { NODE_HEIGHT, type Model } from "./model.ts";
5
+ import type { Rect, Size } from "./viewport.ts";
6
+
7
+ export interface Placed extends Rect { id: string }
8
+ export interface Geometry {
9
+ xs: number[];
10
+ nodes: Record<string, Placed>;
11
+ lanes: Record<string, Rect>;
12
+ loops: Record<string, { channelY: number }>;
13
+ bounds: Rect;
14
+ world: Size;
15
+ }
16
+ export interface LayoutOptions { pitch: number; top: number; gap: number; padX: number; visible: (id: string) => boolean }
17
+
18
+ export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
19
+
20
+ const LANE_PAD_TOP = 38; // room for the lane title
21
+ const LANE_PAD_BOTTOM = 14;
22
+
23
+ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geometry {
24
+ const o: LayoutOptions = { ...DEFAULTS, ...opt };
25
+ const xs: number[] = [];
26
+ let x = o.padX;
27
+ for (const c of m.columns) { xs.push(x); x += c.width + o.gap; }
28
+ const world: Size = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
29
+
30
+ const nodes: Record<string, Placed> = {};
31
+ for (const n of m.nodes) {
32
+ const h = NODE_HEIGHT[n.kind] ?? 54;
33
+ if (m.layout === "free") {
34
+ nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
35
+ continue;
36
+ }
37
+ const first = Math.min(Math.max(0, n.col ?? 0), m.columns.length - 1);
38
+ const last = Math.min(m.columns.length - 1, first + (n.colSpan ?? 1) - 1);
39
+ const x0 = xs[first] ?? o.padX;
40
+ const x1 = (xs[last] ?? x0) + (m.columns[last]?.width ?? 0);
41
+ nodes[n.id] = { id: n.id, x: x0, y: o.top + 40 + (n.row ?? 0) * o.pitch - h / 2, w: Math.max(1, x1 - x0), h };
42
+ }
43
+ if (m.layout === "free") world.w = Math.max(world.w, ...Object.values(nodes).map(p => p.x + p.w + o.padX), 1);
44
+
45
+ const loops: Geometry["loops"] = {};
46
+ const lanes: Record<string, Rect> = {};
47
+ const visibleMembers = (id: string) => m.nodes.filter(n => n.lane === id && o.visible(n.id)).map(n => nodes[n.id]!);
48
+ const ordered = m.lanes.map(l => ({ l, mem: visibleMembers(l.id) })).filter(x => x.mem.length)
49
+ .sort((a, b) => Math.min(...a.mem.map(p => p.y)) - Math.min(...b.mem.map(p => p.y)));
50
+ let previousBottom = -Infinity;
51
+ for (const { l, mem } of ordered) {
52
+ const top = Math.min(...mem.map(p => p.y)) - LANE_PAD_TOP;
53
+ const shift = m.layout === "free" ? 0 : Math.max(0, previousBottom + 8 - top);
54
+ if (shift) for (const p of mem) p.y += shift;
55
+ const y1 = top + shift;
56
+ const laneNodeIds = new Set(mem.map(p => p.id));
57
+ // Reserve channel space before shifting the following lane. The exact channel
58
+ // positions are calculated only after every lane has reached its final y.
59
+ const possibleLoops = m.links.filter(link => link.kind === "loop-back" && o.visible(link.from) && o.visible(link.to)
60
+ && (laneNodeIds.has(link.from) || laneNodeIds.has(link.to))).length;
61
+ const bottom = Math.max(...mem.map(p => p.y + p.h));
62
+ const y2 = bottom + (possibleLoops ? 10 + 8 * (possibleLoops - 1) : 0) + LANE_PAD_BOTTOM;
63
+ lanes[l.id] = { x: 8, y: y1, w: world.w - 16, h: y2 - y1 };
64
+ previousBottom = y2;
65
+ }
66
+
67
+ const channelCounts = new Map<string, number>();
68
+ for (const link of m.links) {
69
+ if (link.kind !== "loop-back" || !o.visible(link.from) || !o.visible(link.to)) continue;
70
+ const a = nodes[link.from], b = nodes[link.to];
71
+ if (!a || !b) continue;
72
+ const lower = a.y + a.h >= b.y + b.h ? a : b;
73
+ const laneId = m.nodes.find(n => n.id === lower.id)?.lane;
74
+ if (!laneId || !lanes[laneId]) continue;
75
+ const left = Math.min(a.x + a.w / 2, b.x + b.w / 2);
76
+ const right = Math.max(a.x + a.w / 2, b.x + b.w / 2);
77
+ const laneMembers = visibleMembers(laneId);
78
+ const crossed = laneMembers.filter(p => p.x < right && p.x + p.w > left);
79
+ const lowestBottom = Math.max(a.y + a.h, b.y + b.h, ...crossed.map(p => p.y + p.h));
80
+ const index = channelCounts.get(laneId) ?? 0;
81
+ loops[link.id] = { channelY: lowestBottom + 10 + 8 * index };
82
+ channelCounts.set(laneId, index + 1);
83
+ const lane = lanes[laneId];
84
+ lane.h = Math.max(lane.h, loops[link.id]!.channelY + LANE_PAD_BOTTOM - lane.y);
85
+ }
86
+
87
+ const all = Object.values(lanes);
88
+ let bounds: Rect = { x: 0, y: 0, w: world.w, h: 1 };
89
+ if (all.length) {
90
+ const top = Math.min(...all.map(r => r.y));
91
+ bounds = { x: 8, y: top, w: world.w - 16, h: Math.max(...all.map(r => r.y + r.h)) - top };
92
+ }
93
+ world.h = Math.max(1, bounds.y + bounds.h + 70);
94
+ return { xs, nodes, lanes, loops, bounds, world };
95
+ }
@@ -0,0 +1,20 @@
1
+ /** Replaced with the shared template at library build time. */
2
+ export const SHELL_BODY_HTML = "/*__SHELL_BODY__*/";
3
+ export { mount, type FlowStudio } from "./engine.ts";
4
+ export * from "./model.ts";
5
+ export { autoLayout } from "./autolayout.ts";
6
+ export { computeGeometry } from "./layout.ts";
7
+ export type { Geometry, LayoutOptions, Placed } from "./layout.ts";
8
+ export * from "./viewport.ts";
9
+ export * from "./routing.ts";
10
+ export * from "./trace.ts";
11
+ export * from "./rules.ts";
12
+ export * from "./playback.ts";
13
+ export * from "./diff.ts";
14
+ export * as viewport from "./viewport.ts";
15
+ export * as routing from "./routing.ts";
16
+ export * as trace from "./trace.ts";
17
+ export * as rules from "./rules.ts";
18
+ export * as playback from "./playback.ts";
19
+ export * as diff from "./diff.ts";
20
+ export { stateCss } from "./stateCss.ts";
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sparklinePoints, metricBadge, sparkline } from "./metrics.ts";
4
+
5
+ test("sparkline handles flat, missing and varied series", () => {
6
+ assert.equal(sparklinePoints([], 56, 12), "");
7
+ assert.equal(sparklinePoints([4, 4], 56, 12), "0,6 56,6");
8
+ assert.equal(sparklinePoints([0, 10], 56, 12), "0,12 56,0");
9
+ });
10
+
11
+ test("metricBadge renders value, unit, and consistent title", () => {
12
+ assert.equal(metricBadge(undefined), "");
13
+ const badge1 = metricBadge({ value: 92, unit: "ms", label: "Hit rate" });
14
+ assert.ok(badge1.includes('title="Hit rate: 92 ms"'), "Title includes label, value and unit");
15
+ assert.ok(badge1.includes(">92 ms"), "Badge body includes value and unit");
16
+ assert.ok(!badge1.includes("<i"), "No fill bar when max is omitted");
17
+ });
18
+
19
+ test("metricBadge renders clamped fill bar when max is valid, ignores invalid max", () => {
20
+ const withMax = metricBadge({ value: 50, max: 100, label: "CPU" });
21
+ assert.ok(withMax.includes('<i style="width:50%"></i>'));
22
+
23
+ const clamped = metricBadge({ value: 150, max: 100 });
24
+ assert.ok(clamped.includes('<i style="width:100%"></i>'));
25
+
26
+ const negativeVal = metricBadge({ value: -10, max: 100 });
27
+ assert.ok(negativeVal.includes('<i style="width:0%"></i>'));
28
+
29
+ const nullMax = metricBadge({ value: 50, max: null as any });
30
+ assert.ok(!nullMax.includes("<i"), "null max produces no bar");
31
+
32
+ const zeroMax = metricBadge({ value: 50, max: 0 });
33
+ assert.ok(!zeroMax.includes("<i"), "zero max produces no bar");
34
+ });
35
+
36
+ test("metricBadge escapes special HTML characters", () => {
37
+ const hostile = metricBadge({ value: 1, unit: '<script>"&', label: '<tag>&"' });
38
+ assert.ok(!hostile.includes("<script>"));
39
+ assert.ok(!hostile.includes("<tag>"));
40
+ assert.ok(hostile.includes("&lt;script&gt;"));
41
+ assert.ok(hostile.includes("&lt;tag&gt;"));
42
+ });
43
+ test("metricBadge escapes quotes so nothing can break out of the title attribute", () => {
44
+ // label and unit land inside title="…": a raw quote there would open attacker-controlled attributes.
45
+ const hostile = metricBadge({ value: 1, unit: '">&x', label: 'y" onmouseover="z' });
46
+ assert.ok(hostile.includes('title="y&quot; onmouseover=&quot;z: 1 &quot;&gt;&amp;x"'));
47
+ });
48
+
49
+ test("sparkline renders accessible SVG with aria-hidden", () => {
50
+ assert.equal(sparkline(undefined), "");
51
+ assert.equal(sparkline({ value: 10 }), "");
52
+ assert.equal(sparkline({ value: 10, series: [1] }), "");
53
+
54
+ const svg = sparkline({ value: 10, series: [2, 8] }, 56, 12);
55
+ assert.ok(svg.startsWith('<svg class="spark" width="56" height="12" viewBox="0 0 56 12" aria-hidden="true">'));
56
+ assert.ok(svg.includes('<polyline points="0,12 56,0"/>'));
57
+ });
@@ -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
+ }
@@ -0,0 +1,167 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateModel, esc, type Model } from "./model.ts";
4
+
5
+ const base = (): Model => ({
6
+ version: 1,
7
+ meta: { title: "T", help: "h", asOf: "2026-01-01", source: "s" },
8
+ layout: "columns-lanes",
9
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
10
+ lanes: [{ id: "l1", title: "Lane 1" }],
11
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
12
+ nodes: [
13
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
14
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "ok", title: "B" },
15
+ ],
16
+ links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
17
+ });
18
+ const codes = (m: unknown) => validateModel(m).filter(i => i.level === "error").map(i => i.code);
19
+
20
+ test("a valid model has no errors", () => assert.deepEqual(codes(base()), []));
21
+ test("not an object", () => assert.deepEqual(codes(null), ["model-shape"]));
22
+ test("link to a missing node names the link", () => {
23
+ const m = base(); m.links[0]!.to = "zzz";
24
+ const issues = validateModel(m);
25
+ assert.ok(issues.some(i => i.code === "link-endpoint" && i.where === "links[a-b]"));
26
+ });
27
+ test("duplicate node id", () => { const m = base(); m.nodes[1]!.id = "a"; assert.ok(codes(m).includes("node-duplicate")); });
28
+ test("undefined state on a node", () => { const m = base(); m.nodes[0]!.state = "nope"; assert.ok(codes(m).includes("state-undefined")); });
29
+ test("node in an unknown lane", () => { const m = base(); m.nodes[0]!.lane = "zz"; assert.ok(codes(m).includes("lane-undefined")); });
30
+ test("column out of range", () => { const m = base(); m.nodes[0]!.col = 5; assert.ok(codes(m).includes("column-range")); });
31
+ test("colSpan that runs past the last column", () => { const m = base(); m.nodes[1]!.colSpan = 3; assert.ok(codes(m).includes("column-range")); });
32
+ test("two nodes in the same cell overlap", () => {
33
+ const m = base(); m.nodes[1]!.col = 0; m.links = [];
34
+ assert.ok(codes(m).includes("cell-overlap"));
35
+ });
36
+ test("rows less than one apart in the same column overlap", () => {
37
+ const m = base(); m.nodes[1]!.col = 0; m.nodes[1]!.row = 0.5;
38
+ assert.ok(codes(m).includes("cell-overlap"));
39
+ });
40
+ test("an orphan node is an error unless allowed", () => {
41
+ const m = base(); m.links = [];
42
+ assert.equal(codes(m).filter(c => c === "orphan").length, 2);
43
+ m.nodes[0]!.allowIsolated = true; m.nodes[1]!.allowIsolated = true;
44
+ assert.deepEqual(codes(m), []);
45
+ });
46
+ test("a chip must point at a real node", () => {
47
+ const m = base(); m.nodes.push({ id: "c", kind: "chip", lane: "l1", col: 0, row: 1, state: "ok", title: "c", ref: "zzz" });
48
+ assert.ok(codes(m).includes("chip-ref"));
49
+ });
50
+ test("empty model is reported, not thrown", () => {
51
+ const m = base(); m.nodes = []; m.links = [];
52
+ const issues = validateModel(m);
53
+ assert.ok(issues.some(i => i.code === "model-empty"));
54
+ });
55
+ test("duplicate link id", () => { const m = base(); m.links.push({ ...m.links[0]! }); assert.ok(codes(m).includes("link-duplicate")); });
56
+ test("a state with an unknown tone is rejected", () => {
57
+ const m = base(); (m.states.ok as any).tone = "purple";
58
+ assert.ok(codes(m).includes("state-shape"));
59
+ });
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";
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"));
84
+ });
85
+ test("a link that goes backward or sideways is a warning, not silence", () => {
86
+ const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a";
87
+ const issues = validateModel(m);
88
+ assert.ok(issues.some(i => i.level === "warn" && i.code === "link-direction" && i.where === "links[a-b]"));
89
+ assert.deepEqual(codes(m), []);
90
+ });
91
+ test("a forward link has no direction warning", () => {
92
+ assert.ok(!validateModel(base()).some(i => i.code === "link-direction"));
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
+ });
114
+ test("a model with no meta is an error, not a crash later", () => {
115
+ const m = base(); delete (m as any).meta;
116
+ assert.ok(codes(m).includes("meta-missing"));
117
+ });
118
+ test("a node row that is not a finite number is an error (it would draw NaN)", () => {
119
+ const m = base(); (m.nodes[0] as any).row = "zero"; (m.nodes[1] as any).row = NaN;
120
+ assert.equal(codes(m).filter(c => c === "row-invalid").length, 2);
121
+ });
122
+ test("a node that is not an object is reported, never thrown (review focus 1)", () => {
123
+ const m = base(); (m.nodes as any[]).push(null, 7, "x");
124
+ assert.doesNotThrow(() => validateModel(m));
125
+ assert.equal(codes(m).filter(c => c === "node-shape").length, 3);
126
+ });
127
+ test("a link that is not an object is reported, never thrown", () => {
128
+ const m = base(); (m.links as any[]).push(null);
129
+ assert.doesNotThrow(() => validateModel(m));
130
+ assert.ok(codes(m).includes("link-shape"));
131
+ });
132
+ test("a node id that is not a string is an error", () => {
133
+ const m = base(); (m.nodes[0] as any).id = 5;
134
+ assert.ok(codes(m).includes("node-shape"));
135
+ });
136
+ test("lanes that are not an array do not make the validator throw", () => {
137
+ const m = base(); (m as any).lanes = "abc";
138
+ assert.doesNotThrow(() => validateModel(m));
139
+ assert.ok(codes(m).includes("lanes-missing"));
140
+ });
141
+ test("a state id must be usable as a CSS class", () => {
142
+ const m = base(); m.states['bad id"'] = m.states.ok!;
143
+ assert.ok(codes(m).includes("state-id"));
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
+ });
152
+ test("esc turns markup into text", () => {
153
+ assert.equal(esc(`<img src=x onerror="a()">&'`), "&lt;img src=x onerror=&quot;a()&quot;&gt;&amp;&#39;");
154
+ assert.equal(esc(undefined), "");
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
+ });