@caddis/cli 0.4.45 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/flow-studio/README.md +46 -7
  3. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +81 -81
  4. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +27 -8
  14. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +177 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +385 -49
  32. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +54 -14
  35. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
  36. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
  37. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +37 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +5 -1
  57. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +11 -7
  60. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +83 -32
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +62 -2
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +66 -18
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +7 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +44 -10
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +995 -82
  92. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
  93. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +191 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  97. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
  98. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +81 -81
  99. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -0
  100. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  101. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  102. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  103. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  104. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  105. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  106. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +27 -8
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +177 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +385 -49
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +54 -14
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +37 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +5 -1
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +11 -7
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +83 -32
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +62 -2
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +66 -18
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +7 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +44 -10
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +995 -82
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +191 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  192. package/bundles/manifest.json +2 -2
  193. package/package.json +1 -1
@@ -0,0 +1,92 @@
1
+ // Build and test both React routes in a disposable application.
2
+ // Run from the skill folder: node scripts/react-proof.mjs
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { cpSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createServer } from 'node:net';
9
+ import { stopPreview } from './lib/react-proof-cleanup.mjs';
10
+ import { proofIndexHtml } from './lib/react-proof-page.mjs';
11
+
12
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const tmp = mkdtempSync(join(tmpdir(), 'flow-studio-react-proof-'));
14
+ let preview;
15
+ let previewClosed;
16
+
17
+ function run(command, args, cwd = tmp) {
18
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' && command.toLowerCase().endsWith('npm.cmd'), env: process.env, timeout: 600_000 });
19
+ if (result.error) throw result.error;
20
+ if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited ${result.status ?? result.signal}`);
21
+ }
22
+
23
+ async function freePort() {
24
+ return new Promise((resolvePort, reject) => {
25
+ const server = createServer();
26
+ server.once('error', reject);
27
+ server.listen(0, '127.0.0.1', () => {
28
+ const address = server.address();
29
+ server.close(() => resolvePort(address.port));
30
+ });
31
+ });
32
+ }
33
+
34
+ async function waitFor(url) {
35
+ for (let i = 0; i < 100; i++) {
36
+ if (preview.exitCode !== null) throw new Error(`vite preview exited ${preview.exitCode}`);
37
+ try { if ((await fetch(url)).ok) return; } catch { /* server still starting */ }
38
+ await new Promise(r => setTimeout(r, 100));
39
+ }
40
+ throw new Error(`vite preview did not start at ${url}`);
41
+ }
42
+
43
+ try {
44
+ mkdirSync(join(tmp, 'src'));
45
+ cpSync(join(root, 'react'), join(tmp, 'react'), { recursive: true });
46
+ // The React files use ../src imports. Keep the real source path in the throwaway app.
47
+ cpSync(join(root, 'src'), join(tmp, 'src'), { recursive: true });
48
+ mkdirSync(join(tmp, 'templates'));
49
+ for (const name of ['flow-studio.lib.mjs', 'flow-studio.lib.d.mts', 'flow-studio.css'])
50
+ cpSync(join(root, 'templates', name), join(tmp, 'templates', name));
51
+ mkdirSync(join(tmp, 'examples'));
52
+ cpSync(join(root, 'examples', 'order-desk.json'), join(tmp, 'examples', 'order-desk.json'));
53
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({
54
+ private: true, type: 'module', scripts: { test: 'vitest run', build: 'tsc --noEmit && vite build' },
55
+ dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1' },
56
+ devDependencies: { '@testing-library/react': '^16.0.0', '@types/react': '^18.3.0', '@types/react-dom': '^18.3.0', jsdom: '^26.0.0', playwright: '^1.51.0', typescript: '^5.0.0', vite: '^6.0.0', vitest: '^3.0.0' },
57
+ }, null, 2));
58
+ writeFileSync(join(tmp, 'index.html'), proofIndexHtml());
59
+ writeFileSync(join(tmp, 'tsconfig.json'), JSON.stringify({ compilerOptions: {
60
+ target: 'ES2022', useDefineForClassFields: true, lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', skipLibCheck: true,
61
+ moduleResolution: 'Bundler', allowImportingTsExtensions: true, resolveJsonModule: true, isolatedModules: true,
62
+ noEmit: true, jsx: 'react-jsx', strict: true, types: ['vitest/globals'],
63
+ }, include: ['src', 'react', 'templates'], exclude: ['src/**/*.test.ts'] }, null, 2));
64
+ writeFileSync(join(tmp, 'src', 'App.tsx'), "import { FlowStudioView } from '../react/index.ts';\nimport model from '../examples/order-desk.json';\nimport '../templates/flow-studio.css';\nimport type { Model } from './model.ts';\nexport default function App() { return <FlowStudioView model={model as Model} />; }\n");
65
+ writeFileSync(join(tmp, 'src', 'main.tsx'), "import { StrictMode } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App.tsx';\ncreateRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);\n");
66
+ console.log(`Proof app: ${tmp}`);
67
+ // The browser checks launch a real browser. On Windows and when FLOW_STUDIO_CHANNEL is set,
68
+ // check.mjs uses a system browser (Edge or the named channel), so the Playwright browser
69
+ // download is skipped there. Everywhere else, let npm fetch Playwright's own browser, or the
70
+ // checks would fail on a clean machine that never downloaded one.
71
+ if (process.env.FLOW_STUDIO_CHANNEL || process.platform === 'win32') process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD ??= '1';
72
+ process.env.npm_config_cache = join(tmp, '.npm-cache');
73
+ run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '--no-audit', '--no-fund']);
74
+ run(process.execPath, [join(tmp, 'node_modules', 'vitest', 'vitest.mjs'), 'run', 'react/FlowStudioView.test.tsx', 'react/native/native.test.tsx', '--environment', 'jsdom']);
75
+ run(process.execPath, [join(tmp, 'node_modules', 'typescript', 'bin', 'tsc'), '--noEmit']);
76
+ run(process.execPath, [join(tmp, 'node_modules', 'vite', 'bin', 'vite.js'), 'build']);
77
+ const port = await freePort();
78
+ const address = `http://127.0.0.1:${port}/`;
79
+ preview = spawn(process.execPath, [join(tmp, 'node_modules', 'vite', 'bin', 'vite.js'), 'preview', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: tmp, stdio: 'inherit' });
80
+ previewClosed = new Promise(resolve => preview.once('close', resolve));
81
+ await waitFor(address);
82
+ const checkArgs = [join(root, 'scripts', 'check.mjs'), address, '--out', join(tmp, 'check-out'), '--skip', 'hostile'];
83
+ if (process.env.FLOW_STUDIO_CHANNEL || process.platform === 'win32') checkArgs.push('--channel', process.env.FLOW_STUDIO_CHANNEL ?? 'msedge');
84
+ run(process.execPath, checkArgs, tmp);
85
+ console.log('React proof passed.');
86
+ } catch (error) {
87
+ console.error(`React proof failed: ${error.message}`);
88
+ process.exitCode = 1;
89
+ } finally {
90
+ await stopPreview(preview, previewClosed);
91
+ rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
92
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
  import { validateModel } from "../src/model.ts";
7
+ import { autoLayout } from "../src/autolayout.ts";
8
+ import { prepareModel } from "./lib/auto-entry-guard.mjs";
7
9
 
8
10
  export function loadModel(path) {
9
11
  let text;
@@ -32,7 +34,9 @@ if (process.argv[1]?.endsWith("validate.mjs")) {
32
34
  console.error("usage: node --experimental-strip-types scripts/validate.mjs <model.json>");
33
35
  process.exit(2);
34
36
  }
35
- const errors = report(validateModel(loadModel(path)));
37
+ const model = loadModel(path);
38
+ const ready = prepareModel(model, autoLayout);
39
+ const errors = report(validateModel(ready));
36
40
  console.log(errors ? `${errors} error(s)` : "OK");
37
41
  process.exit(errors ? 1 : 0);
38
42
  }
@@ -0,0 +1,78 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { autoLayout } from "./autolayout.ts";
4
+ import { validateModel, type Model, type NodeDef } from "./model.ts";
5
+ import { computeGeometry } from "./layout.ts";
6
+
7
+ const node = (id: string, extra: Partial<NodeDef> = {}): NodeDef => ({ id, kind: "card", state: "ok", title: id, ...extra } as NodeDef);
8
+ const model = (ids: string[], pairs: [string, string][]): Model => ({
9
+ version: 1, meta: { title: "t", help: "", asOf: "", source: "" }, layout: "auto", columns: [], lanes: [],
10
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
11
+ nodes: ids.map(id => node(id)), links: pairs.map(([from, to], i) => ({ id: `e${i}`, from, to, state: "ok" })),
12
+ });
13
+ const errors = (m: Model) => validateModel(m).filter(i => i.level === "error");
14
+
15
+ test("chain gets longest path columns and generated stages", () => {
16
+ const out = autoLayout(model(["a", "b", "c"], [["a", "b"], ["b", "c"]]));
17
+ assert.deepEqual(out.nodes.map(n => n.col), [0, 1, 2]);
18
+ assert.deepEqual(out.columns.map(c => c.title), ["Stage 1", "Stage 2", "Stage 3"]);
19
+ assert.deepEqual(errors(out), []);
20
+ });
21
+ test("fork and join ranks the join after both branches", () => {
22
+ const out = autoLayout(model(["a", "b", "c", "d"], [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]));
23
+ assert.equal(out.nodes.find(n => n.id === "d")?.col, 2);
24
+ assert.equal(out.nodes.find(n => n.id === "b")?.col, 1);
25
+ assert.equal(out.nodes.find(n => n.id === "c")?.col, 1);
26
+ assert.deepEqual(errors(out), []);
27
+ });
28
+ test("a three cycle yields exactly one feedback edge and consistent ranks", () => {
29
+ const out = autoLayout(model(["a", "b", "c"], [["a", "b"], ["b", "c"], ["c", "a"]]));
30
+ assert.deepEqual(out.links.filter(l => l.kind === "loop-back").map(l => l.id), ["e2"]);
31
+ assert.deepEqual(out.nodes.map(n => n.col), [0, 1, 2]);
32
+ assert.deepEqual(errors(out), []);
33
+ });
34
+ test("disconnected parts get synthetic lanes in first appearance order", () => {
35
+ const out = autoLayout(model(["a", "b", "c", "d"], [["a", "b"], ["c", "d"]]));
36
+ assert.deepEqual(out.lanes.map(l => l.title), ["Group 1", "Group 2"]);
37
+ assert.equal(out.nodes[0]?.lane, out.nodes[1]?.lane);
38
+ assert.equal(out.nodes[2]?.lane, out.nodes[3]?.lane);
39
+ assert.notEqual(out.nodes[0]?.lane, out.nodes[2]?.lane);
40
+ const lanes = Object.values(computeGeometry(out).lanes).sort((a, b) => a.y - b.y);
41
+ assert.ok(lanes[0]!.y + lanes[0]!.h <= lanes[1]!.y);
42
+ });
43
+ test("author fixed column and row stay fixed and successors rank from them", () => {
44
+ const m = model(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
45
+ m.nodes[1]!.col = 4; m.nodes[1]!.row = 7;
46
+ const out = autoLayout(m);
47
+ assert.equal(out.nodes[1]?.col, 4);
48
+ assert.equal(out.nodes[1]?.row, 7);
49
+ assert.equal(out.nodes[2]?.col, 5);
50
+ assert.equal(out.columns.length, 6);
51
+ assert.deepEqual(errors(out), []);
52
+ });
53
+ test("ordering lowers crossings on a reversed two-edge example", () => {
54
+ const m = model(["a", "b", "d", "c"], [["a", "c"], ["b", "d"]]);
55
+ m.nodes[0]!.col = 0; m.nodes[1]!.col = 0; m.nodes[2]!.col = 1; m.nodes[3]!.col = 1;
56
+ m.nodes.forEach(n => n.lane = "one"); m.lanes = [{ id: "one", title: "One" }];
57
+ const out = autoLayout(m), byId = new Map(out.nodes.map(n => [n.id, n]));
58
+ const crossing = (a: string, b: string, c: string, d: string): boolean =>
59
+ ((byId.get(a)!.row! - byId.get(c)!.row!) * (byId.get(b)!.row! - byId.get(d)!.row!)) < 0;
60
+ assert.equal(crossing("a", "c", "b", "d"), false);
61
+ assert.deepEqual(errors(out), []);
62
+ });
63
+ test("same input gives equal output without changing input", () => {
64
+ const m = model(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
65
+ const before = JSON.stringify(m);
66
+ assert.deepEqual(autoLayout(m), autoLayout(m));
67
+ assert.equal(JSON.stringify(m), before);
68
+ });
69
+ test("seeded 200 node graph finishes under 300ms and validates", () => {
70
+ const ids = Array.from({ length: 200 }, (_, i) => `n${i}`);
71
+ let seed = 39;
72
+ const rnd = () => { seed = (1664525 * seed + 1013904223) >>> 0; return seed / 0x100000000; };
73
+ const pairs: [string, string][] = ids.slice(1).map((id, i) => [ids[i]!, id]);
74
+ for (let i = 0; i < 200; i++) pairs.push([ids[Math.floor(rnd() * ids.length)]!, ids[Math.floor(rnd() * ids.length)]!]);
75
+ const m = model(ids, pairs), start = performance.now(), out = autoLayout(m);
76
+ assert.ok(performance.now() - start < 300);
77
+ assert.deepEqual(errors(out), []);
78
+ });
@@ -0,0 +1,132 @@
1
+ // Deterministic placement for models without authored grid positions. No DOM and no input mutation.
2
+ import type { Model, NodeDef, LinkDef } from "./model.ts";
3
+
4
+ export function autoLayout(input: Model): Model {
5
+ const nodes = input.nodes.map(n => ({ ...n }));
6
+ const links = input.links.map(l => ({ ...l }));
7
+ const columns = input.columns.map(c => ({ ...c }));
8
+ const lanes = input.lanes.map(l => ({ ...l }));
9
+ const byId = new Map(nodes.map(n => [n.id, n]));
10
+ const index = new Map(nodes.map((n, i) => [n.id, i]));
11
+ const outgoing = new Map(nodes.map(n => [n.id, [] as LinkDef[]]));
12
+ const incoming = new Map(nodes.map(n => [n.id, [] as LinkDef[]]));
13
+ const neighbours = new Map(nodes.map(n => [n.id, [] as string[]]));
14
+ for (const link of links) {
15
+ if (!byId.has(link.from) || !byId.has(link.to)) continue;
16
+ outgoing.get(link.from)!.push(link);
17
+ incoming.get(link.to)!.push(link);
18
+ neighbours.get(link.from)!.push(link.to);
19
+ neighbours.get(link.to)!.push(link.from);
20
+ }
21
+
22
+ // A gray target is on the active DFS stack. Mark that edge as feedback.
23
+ const color = new Map<string, number>();
24
+ const post: string[] = [];
25
+ for (const root of nodes) {
26
+ if (color.has(root.id)) continue;
27
+ color.set(root.id, 1);
28
+ const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];
29
+ while (stack.length) {
30
+ const frame = stack[stack.length - 1]!;
31
+ const edges = outgoing.get(frame.id)!;
32
+ if (frame.next >= edges.length) {
33
+ color.set(frame.id, 2); post.push(frame.id); stack.pop(); continue;
34
+ }
35
+ const link = edges[frame.next++]!;
36
+ if (link.kind === "loop-back") continue;
37
+ const state = color.get(link.to) ?? 0;
38
+ if (state === 1) link.kind = "loop-back";
39
+ else if (state === 0) { color.set(link.to, 1); stack.push({ id: link.to, next: 0 }); }
40
+ }
41
+ }
42
+
43
+ // Reverse finishing order is topological after feedback edges are removed.
44
+ for (const id of post.reverse()) {
45
+ const n = byId.get(id)!;
46
+ if (n.col !== undefined) continue;
47
+ let col = 0;
48
+ for (const edge of incoming.get(id)!) {
49
+ if (edge.kind === "loop-back") continue;
50
+ const parent = byId.get(edge.from)!;
51
+ col = Math.max(col, (parent.col ?? 0) + (parent.colSpan ?? 1));
52
+ }
53
+ n.col = col;
54
+ }
55
+
56
+ // Missing lane membership is grouped by weak connection, including feedback edges.
57
+ const seen = new Set<string>();
58
+ const usedLaneIds = new Set(lanes.map(l => l.id));
59
+ let group = 0;
60
+ for (const first of nodes) {
61
+ if (seen.has(first.id)) continue;
62
+ const queue = [first.id]; seen.add(first.id);
63
+ for (let q = 0; q < queue.length; q++) {
64
+ for (const id of neighbours.get(queue[q]!)!) if (!seen.has(id)) { seen.add(id); queue.push(id); }
65
+ }
66
+ const missing = queue.map(id => byId.get(id)!).filter(n => !n.lane);
67
+ if (!missing.length) continue;
68
+ group++;
69
+ let laneId = `group-${group}`;
70
+ while (usedLaneIds.has(laneId)) laneId += "-auto";
71
+ usedLaneIds.add(laneId);
72
+ lanes.push({ id: laneId, title: `Group ${group}` });
73
+ for (const n of missing) n.lane = laneId;
74
+ }
75
+
76
+ // Start in input order. Four alternating sweeps sort each lane and layer by
77
+ // neighbour barycentre. Stable input order resolves every tie.
78
+ const rank = new Map(nodes.map((n, i) => [n.id, i]));
79
+ const laneOrder = lanes.map(l => l.id);
80
+ const cells = new Map<string, NodeDef[]>();
81
+ for (const n of nodes) {
82
+ const key = `${n.lane}\0${n.col}`;
83
+ if (!cells.has(key)) cells.set(key, []);
84
+ cells.get(key)!.push(n);
85
+ }
86
+ const maxCol = Math.max(0, ...nodes.map(n => (n.col ?? 0) + (n.colSpan ?? 1) - 1));
87
+ for (let sweep = 0; sweep < 4; sweep++) {
88
+ const forward = sweep % 2 === 0;
89
+ const layers = Array.from({ length: maxCol + 1 }, (_, i) => forward ? i : maxCol - i);
90
+ for (const lane of laneOrder) for (const col of layers) {
91
+ const groupNodes = cells.get(`${lane}\0${col}`);
92
+ if (!groupNodes || groupNodes.length < 2) continue;
93
+ const score = (n: NodeDef): number => {
94
+ const edges = forward ? incoming.get(n.id)! : outgoing.get(n.id)!;
95
+ const ns = edges.filter(e => e.kind !== "loop-back").map(e => forward ? e.from : e.to);
96
+ return ns.length ? ns.reduce((sum, id) => sum + (rank.get(id) ?? 0), 0) / ns.length : (rank.get(n.id) ?? 0);
97
+ };
98
+ groupNodes.sort((a, b) => score(a) - score(b) || (index.get(a.id)! - index.get(b.id)!));
99
+ groupNodes.forEach((n, i) => rank.set(n.id, i));
100
+ }
101
+ }
102
+
103
+ // Place lanes in separate row ranges. A cell can share a row with another
104
+ // column, but nodes whose column spans intersect need a full row of space.
105
+ let nextBase = 0;
106
+ const placed: NodeDef[] = nodes.filter(n => n.row !== undefined);
107
+ for (const lane of laneOrder) {
108
+ const members = nodes.filter(n => n.lane === lane);
109
+ if (!members.length) continue;
110
+ const ordered = [...members].sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || index.get(a.id)! - index.get(b.id)!);
111
+ const rowAtCol = new Map<number, number>();
112
+ for (const n of ordered) {
113
+ if (n.row !== undefined) continue;
114
+ let row = Math.max(nextBase, rowAtCol.get(n.col ?? 0) ?? nextBase);
115
+ while (placed.some(p => (n.col ?? 0) < (p.col ?? 0) + (p.colSpan ?? 1)
116
+ && (p.col ?? 0) < (n.col ?? 0) + (n.colSpan ?? 1) && Math.abs(row - (p.row ?? 0)) < 1)) row += 1;
117
+ n.row = row;
118
+ placed.push(n);
119
+ rowAtCol.set(n.col ?? 0, row + 1);
120
+ }
121
+ const last = Math.max(nextBase - 1, ...members.map(n => n.row ?? 0));
122
+ const loops = links.filter(l => l.kind === "loop-back" && byId.get(l.from)?.lane === lane && byId.get(l.to)?.lane === lane).length;
123
+ nextBase = last + 1.75 + loops * 0.35;
124
+ }
125
+
126
+ const needed = Math.max(0, ...nodes.map(n => (n.col ?? 0) + (n.colSpan ?? 1)));
127
+ while (columns.length < needed) {
128
+ const i = columns.length + 1;
129
+ columns.push({ id: `stage-${i}`, title: `Stage ${i}`, width: 190 });
130
+ }
131
+ return { ...input, nodes, links, columns, lanes };
132
+ }
@@ -4,7 +4,7 @@
4
4
  import { el, on, type Ctx } from "./ctx.ts";
5
5
  import { esc } from "./model.ts";
6
6
  import { DASH, toneVar, cssId } from "./stateCss.ts";
7
- import { bucketOf, linkStateId, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
7
+ import { bucketOf, columnTitle, effectiveLinks, nodeStateId, nodeVisible } from "./rules.ts";
8
8
  import { matches } from "./interact.ts";
9
9
 
10
10
  const DEFAULT_LABELS: Record<string, string> = {
@@ -19,7 +19,7 @@ const DEFAULT_LABELS: Record<string, string> = {
19
19
  export function install(ctx: Ctx): void {
20
20
  const { els, model } = ctx;
21
21
  const label = (k: string): string => model.meta.labels?.[k] ?? DEFAULT_LABELS[k] ?? k;
22
- const visible = (id: string): boolean => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, ctx.mode, n); };
22
+ const visible = (id: string): boolean => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, ctx.mode, n, ctx.collapsed); };
23
23
 
24
24
  function countUp(node: HTMLElement, to: number, ms = 700): void {
25
25
  if (ctx.reduced) { node.textContent = String(to); return; }
@@ -34,6 +34,7 @@ export function install(ctx: Ctx): void {
34
34
 
35
35
  function buildStrip(): void {
36
36
  const box = els.strip;
37
+ box.style.display = model.columns.length ? "" : "none";
37
38
  box.style.gridTemplateColumns = `repeat(${Math.max(1, model.columns.length)}, minmax(0, 1fr))`;
38
39
  if (!box.firstChild) {
39
40
  box.innerHTML = model.columns.map(c => `<div class="lcell"><div class="ln">${esc(c.title)}</div><div class="lb"><i class="good"></i><i class="neutral"></i><i class="gap"></i></div><div class="lv"></div></div>`).join("");
@@ -43,9 +44,9 @@ export function install(ctx: Ctx): void {
43
44
  let tot = 0, good = 0, neutral = 0, gap = 0;
44
45
  for (const n of model.nodes) {
45
46
  if (n.col !== i || n.kind === "chip") continue;
47
+ if (!visible(n.id)) continue;
46
48
  const w = Math.max(1, n.weight ?? 1);
47
49
  tot += w;
48
- if (!visible(n.id)) continue;
49
50
  const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
50
51
  if (b === "good") good += w; else if (b === "neutral") neutral += w; else gap += w;
51
52
  }
@@ -64,9 +65,8 @@ export function install(ctx: Ctx): void {
64
65
  function buildTiles(): void {
65
66
  const k = els.tiles;
66
67
  let good = 0, neutral = 0;
67
- for (const l of ctx.links) {
68
- if (!linkVisible(model, ctx.mode, l, ctx.nodes)) continue;
69
- const b = bucketOf(model, linkStateId(model, ctx.mode, l));
68
+ for (const l of effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes)) {
69
+ const b = bucketOf(model, l.state);
70
70
  if (b === "good") good++; else if (b === "neutral") neutral++;
71
71
  }
72
72
  let gaps = 0, upgrades = 0;
@@ -120,7 +120,7 @@ export function install(ctx: Ctx): void {
120
120
  const rows = model.nodes.filter(n => visible(n.id) && n.kind !== "chip").map(n => {
121
121
  const sid = nodeStateId(model, ctx.mode, n);
122
122
  const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
123
- return `<tr><th scope="row">${esc(model.columns[n.col]?.title ?? "")}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
123
+ return `<tr><th scope="row">${esc(columnTitle(model, n))}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
124
124
  }).join("");
125
125
  els.table.innerHTML = `<thead><tr><th scope="col">Column</th><th scope="col">Lane</th><th scope="col">Group</th><th scope="col">State</th><th scope="col">Members</th><th scope="col">Detail</th><th scope="col">Can improve</th></tr></thead><tbody>${rows}</tbody>`;
126
126
  }
@@ -157,6 +157,10 @@ export function install(ctx: Ctx): void {
157
157
  els.matchinfo.textContent = ctx.query ? `${n} group${n === 1 ? "" : "s"} match` : "";
158
158
  if (!ctx.selected) { ctx.fn.trace(null); ctx.fn.renderInspector(); }
159
159
  });
160
+ on(ctx, els.hops, "change", () => {
161
+ ctx.hops = Number(els.hops.value);
162
+ ctx.fn.trace(ctx.selected);
163
+ });
160
164
  on(ctx, els.q, "keydown", (e: KeyboardEvent) => {
161
165
  if (e.key !== "Enter" || !ctx.query) return;
162
166
  const ids = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).map(x => x.id);
@@ -69,6 +69,7 @@ export function install(ctx: Ctx): void {
69
69
 
70
70
  function placeHeaders(): void {
71
71
  const cols = els.cols;
72
+ cols.style.display = model.columns.length ? "" : "none";
72
73
  if (!cols.firstChild) {
73
74
  model.columns.forEach((c, i) => { const h = el("div", "hd"); h.textContent = (c.short ?? c.title).toUpperCase(); h.title = c.title; h.dataset.i = String(i); cols.appendChild(h); });
74
75
  let i = 0;
@@ -209,6 +210,13 @@ export function install(ctx: Ctx): void {
209
210
  else if (k === "n" || k === "N") ctx.fn.toggleNotes();
210
211
  else if (k === "v" || k === "V") setTool("select");
211
212
  else if (k === "h" || k === "H") setTool("hand");
213
+ else if (k === "e" || k === "E") {
214
+ const selected = ctx.selected && ctx.nodes.get(ctx.selected);
215
+ if (selected) {
216
+ const group = selected.kind === "group" ? selected : ctx.model.nodes.find(n => n.kind === "group" && n.contains?.includes(selected.id));
217
+ if (group) ctx.fn.toggleGroup(group.id);
218
+ }
219
+ }
212
220
  else if (k === "+" || k === "=") zoomBy(1.25);
213
221
  else if (k === "-" || k === "_") zoomBy(1 / 1.25);
214
222
  else if (k === "0") { ctx.userMoved = false; fit(true); }
@@ -22,6 +22,7 @@ export interface Els {
22
22
  ledger: HTMLElement;
23
23
  table: HTMLElement;
24
24
  q: HTMLInputElement;
25
+ hops: HTMLSelectElement;
25
26
  matchinfo: HTMLElement;
26
27
  modeSwitch: HTMLElement;
27
28
  drawer: HTMLElement;
@@ -34,6 +35,17 @@ export interface Els {
34
35
  readat: HTMLElement;
35
36
  help: HTMLElement;
36
37
  footer: HTMLElement;
38
+ playbar: HTMLElement;
39
+ pbScenario: HTMLSelectElement;
40
+ pbPlay: HTMLButtonElement;
41
+ pbStep: HTMLButtonElement;
42
+ pbReset: HTMLButtonElement;
43
+ pbSpeed: HTMLSelectElement;
44
+ pbScrub: HTMLInputElement;
45
+ pbCaption: HTMLElement;
46
+ pbCounter: HTMLElement;
47
+ pbChoices: HTMLElement;
48
+ banner: HTMLElement;
37
49
  }
38
50
 
39
51
  export type Filter = "gap" | "upgrade" | null;
@@ -62,12 +74,14 @@ export interface Fns {
62
74
  placeMarkers(): void;
63
75
  escape(): void;
64
76
  setMode(id: string): void;
77
+ toggleGroup(id: string): void;
65
78
  }
66
79
 
67
80
  export interface Ctx {
68
81
  model: Model;
69
82
  root: ParentNode;
70
83
  els: Els;
84
+ onSelect?: (id: string | null) => void;
71
85
  notes: NoteDef[];
72
86
  nodes: Map<string, NodeDef>;
73
87
  links: LinkDef[];
@@ -75,9 +89,11 @@ export interface Ctx {
75
89
  view: View;
76
90
  reduced: boolean;
77
91
  mode: string;
92
+ collapsed: Set<string>;
78
93
  selected: string | null;
79
94
  hl: string | null;
80
95
  query: string;
96
+ hops: number;
81
97
  filter: Filter;
82
98
  tool: "select" | "hand";
83
99
  spaceDown: boolean;
@@ -0,0 +1,31 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { diffModels } from "./diff.ts";
4
+ import type { Model } from "./model.ts";
5
+
6
+ const base = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", sla: 30 }, { id: "b", state: "ok", title: "B", kind: "card" }] } as Model;
7
+ test("diff reports added, removed, and specified field changes", () => {
8
+ const next = { nodes: [{ ...base.nodes[0], metrics: { value: 2 } }, { id: "c", state: "ok", title: "C", kind: "card" }] } as Model;
9
+ assert.deepEqual(diffModels(base, next), { added: ["c"], removed: ["b"], changed: ["a"] });
10
+ assert.deepEqual(diffModels(base, structuredClone(base)), { added: [], removed: [], changed: [] });
11
+ });
12
+ test("diff detects change in sla", () => {
13
+ const next = { nodes: [{ ...base.nodes[0], sla: 60 }, base.nodes[1]] } as Model;
14
+ assert.deepEqual(diffModels(base, next), { added: [], removed: [], changed: ["a"] });
15
+ });
16
+ test("diff is invariant to key order in objects", () => {
17
+ const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 10, unit: "ms", label: "latency" } }] } as Model;
18
+ const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { label: "latency", unit: "ms", value: 10 } }] } as Model;
19
+ assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
20
+ });
21
+ test("an undefined-valued property counts as absent (a model comes from JSON, which cannot hold one)", () => {
22
+ const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1, unit: undefined } }] } as unknown as Model;
23
+ const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1 } }] } as Model;
24
+ assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
25
+ });
26
+ test("null, undefined and NaN stay distinct values in a diff", () => {
27
+ const mk = (metrics: unknown) => ({ nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics }] }) as unknown as Model;
28
+ assert.deepEqual(diffModels(mk({ value: 1, series: [null] }), mk({ value: 1, series: [undefined] })).changed, ["a"]);
29
+ assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: null })).changed, ["a"]);
30
+ assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: NaN })).changed, []);
31
+ });
@@ -0,0 +1,34 @@
1
+ import type { Model, NodeDef } from "./model.ts";
2
+
3
+ const fields: Array<keyof NodeDef> = ["state", "stateIn", "title", "subtitle", "weight", "metrics", "visibleIn", "lane", "sla"];
4
+
5
+ // A stable serializer for the diff. Unlike JSON.stringify it never returns undefined, and values
6
+ // JSON cannot hold stay distinct: undefined, NaN and Infinity each get their own text, so a real
7
+ // change between them cannot be hidden by "null" conflation.
8
+ function stableStringify(val: unknown): string {
9
+ if (val === undefined) return "undefined";
10
+ if (val === null) return "null";
11
+ if (typeof val === "number") return Number.isFinite(val) ? JSON.stringify(val) : String(val);
12
+ if (typeof val === "bigint") return String(val);
13
+ if (typeof val === "object") {
14
+ if (Array.isArray(val)) return `[${val.map(stableStringify).join(",")}]`;
15
+ // A model comes from JSON, which cannot hold an undefined property, so an explicit one counts as absent.
16
+ const rec = val as Record<string, unknown>;
17
+ const keys = Object.keys(rec).filter(k => rec[k] !== undefined).sort();
18
+ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
19
+ }
20
+ return JSON.stringify(val) ?? "undefined";
21
+ }
22
+
23
+ export function diffModels(a: Model, b: Model): { added: string[]; removed: string[]; changed: string[] } {
24
+ const old = new Map(a.nodes.map(n => [n.id, n]));
25
+ const now = new Map(b.nodes.map(n => [n.id, n]));
26
+ return {
27
+ added: b.nodes.filter(n => !old.has(n.id)).map(n => n.id),
28
+ removed: a.nodes.filter(n => !now.has(n.id)).map(n => n.id),
29
+ changed: b.nodes.filter(n => {
30
+ const prev = old.get(n.id);
31
+ return prev && fields.some(k => stableStringify(prev[k]) !== stableStringify(n[k]));
32
+ }).map(n => n.id),
33
+ };
34
+ }