@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
- package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
- package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "flow-studio model",
|
|
4
|
+
"description": "What an author writes. The engine renders it. validate.mjs also enforces rules a schema cannot (unique ids, links that point at real nodes, no overlap, no orphan).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["version", "meta", "layout", "columns", "lanes", "states", "nodes", "links"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": 1 },
|
|
10
|
+
"meta": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["title", "help", "asOf", "source"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"title": { "type": "string" },
|
|
16
|
+
"help": { "type": "string" },
|
|
17
|
+
"asOf": { "type": "string" },
|
|
18
|
+
"source": { "type": "string" },
|
|
19
|
+
"labels": { "type": "object", "additionalProperties": { "type": "string" } }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"layout": { "enum": ["columns-lanes", "free", "auto"] },
|
|
23
|
+
"columns": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object", "required": ["id", "title", "width"], "additionalProperties": false,
|
|
27
|
+
"properties": {
|
|
28
|
+
"id": { "type": "string" }, "title": { "type": "string" }, "short": { "type": "string" },
|
|
29
|
+
"width": { "type": "number", "minimum": 60 }, "phase": { "type": "string" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"lanes": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "object", "required": ["id", "title"], "additionalProperties": false,
|
|
37
|
+
"properties": {
|
|
38
|
+
"id": { "type": "string" }, "title": { "type": "string" }, "badge": { "type": "string" },
|
|
39
|
+
"badgeTone": { "enum": ["ok", "warn", "muted"] }, "note": { "type": "string" }, "gap": { "type": "boolean" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"states": {
|
|
44
|
+
"type": "object", "minProperties": 1,
|
|
45
|
+
"additionalProperties": {
|
|
46
|
+
"type": "object", "required": ["word", "tone", "border", "edge", "motion", "bucket"], "additionalProperties": false,
|
|
47
|
+
"properties": {
|
|
48
|
+
"word": { "type": "string" },
|
|
49
|
+
"tone": { "enum": ["ok", "warn", "crit", "info", "muted", "accent"] },
|
|
50
|
+
"border": { "enum": ["solid", "dashed", "dotted"] },
|
|
51
|
+
"edge": { "enum": ["flow", "dash", "dash-short", "dot"] },
|
|
52
|
+
"motion": { "enum": ["flow", "flow-slow", "breathe", "none"] },
|
|
53
|
+
"bucket": { "enum": ["good", "neutral", "gap"] }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"nodes": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": {
|
|
60
|
+
"type": "object", "required": ["id", "kind", "state", "title"], "additionalProperties": false,
|
|
61
|
+
"properties": {
|
|
62
|
+
"id": { "type": "string" },
|
|
63
|
+
"kind": { "enum": ["card", "group", "chip", "gateway", "start", "end", "event", "placeholder", "note"] },
|
|
64
|
+
"lane": { "type": "string" }, "col": { "type": "integer", "minimum": 0 }, "colSpan": { "type": "integer", "minimum": 1 },
|
|
65
|
+
"row": { "type": "number" }, "x": { "type": "number" }, "y": { "type": "number" },
|
|
66
|
+
"w": { "type": "number", "exclusiveMinimum": 0 }, "h": { "type": "number", "exclusiveMinimum": 0 },
|
|
67
|
+
"state": { "type": "string" },
|
|
68
|
+
"stateIn": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
69
|
+
"visibleIn": { "type": "array", "items": { "type": "string" } },
|
|
70
|
+
"title": { "type": "string" }, "subtitle": { "type": "string" },
|
|
71
|
+
"members": { "type": "array", "items": { "type": "string" } },
|
|
72
|
+
"weight": { "type": "number", "minimum": 0 }, "ref": { "type": "string" },
|
|
73
|
+
"upgrade": { "type": "string" }, "allowIsolated": { "type": "boolean" },
|
|
74
|
+
"contains": { "type": "array", "items": { "type": "string" } }, "collapsed": { "type": "boolean", "default": true, "description": "A group with contains starts collapsed unless collapsed is false." },
|
|
75
|
+
"sla": { "type": "number", "exclusiveMinimum": 0, "description": "Target service level agreement in seconds" },
|
|
76
|
+
"metrics": { "type": "object", "required": ["value"], "additionalProperties": false,
|
|
77
|
+
"properties": { "value": { "type": "number" }, "max": { "type": ["number", "null"], "description": "Upper bound for the badge fill bar; null means there is no maximum" }, "unit": { "type": "string" }, "label": { "type": "string" },
|
|
78
|
+
"series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"links": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {
|
|
85
|
+
"type": "object", "required": ["id", "from", "to", "state"], "additionalProperties": false,
|
|
86
|
+
"properties": {
|
|
87
|
+
"id": { "type": "string" }, "from": { "type": "string" }, "to": { "type": "string" }, "state": { "type": "string" },
|
|
88
|
+
"stateIn": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
89
|
+
"visibleIn": { "type": "array", "items": { "type": "string" } }, "label": { "type": "string" },
|
|
90
|
+
"kind": { "enum": ["forward", "loop-back"] }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"views": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object", "required": ["id", "label"], "additionalProperties": false,
|
|
98
|
+
"properties": { "id": { "type": "string" }, "label": { "type": "string" }, "default": { "type": "boolean" } }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"inspector": {
|
|
102
|
+
"type": "object", "additionalProperties": false,
|
|
103
|
+
"properties": {
|
|
104
|
+
"relations": {
|
|
105
|
+
"type": "array", "maxItems": 3,
|
|
106
|
+
"items": {
|
|
107
|
+
"type": "object", "required": ["id", "label", "dir"], "additionalProperties": false,
|
|
108
|
+
"properties": {
|
|
109
|
+
"id": { "type": "string" }, "label": { "type": "string" }, "dir": { "enum": ["up", "down"] },
|
|
110
|
+
"filter": { "type": "object", "additionalProperties": false, "properties": { "col": { "type": "integer" }, "kind": { "type": "string" } } }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"actions": {
|
|
115
|
+
"type": "array",
|
|
116
|
+
"items": {
|
|
117
|
+
"type": "object", "required": ["id", "label", "dir", "staleText"], "additionalProperties": false,
|
|
118
|
+
"properties": { "id": { "type": "string" }, "label": { "type": "string" }, "dir": { "const": "down" }, "staleText": { "type": "string" } }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"scenarios": { "type": "array", "items": { "type": "object", "required": ["id", "label", "steps"], "additionalProperties": false,
|
|
124
|
+
"properties": { "id": { "type": "string" }, "label": { "type": "string" },
|
|
125
|
+
"steps": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["link"], "additionalProperties": false,
|
|
126
|
+
"properties": { "link": { "type": "string" }, "at": { "type": "number", "minimum": 0, "description": "Relative elapsed offset from scenario start in seconds" }, "dur": { "type": "number", "exclusiveMinimum": 0, "description": "Token travel animation duration in milliseconds (defaults to 900)" }, "caption": { "type": "string" } } } } } } },
|
|
127
|
+
"notes": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"items": {
|
|
130
|
+
"type": "object", "required": ["anchor", "title", "text"], "additionalProperties": false,
|
|
131
|
+
"properties": { "anchor": { "type": "string" }, "title": { "type": "string" }, "text": { "type": "string" } }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"allOf": [
|
|
136
|
+
{ "if": { "properties": { "layout": { "const": "columns-lanes" } } },
|
|
137
|
+
"then": { "properties": { "columns": { "minItems": 1 }, "lanes": { "minItems": 1 },
|
|
138
|
+
"nodes": { "items": { "required": ["lane", "col", "row"] } } } } },
|
|
139
|
+
{ "if": { "properties": { "layout": { "const": "free" } } },
|
|
140
|
+
"then": { "properties": { "lanes": { "minItems": 1 }, "nodes": { "items": { "required": ["lane", "x", "y"] } } } } }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flow-studio",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --experimental-strip-types --test src/*.test.ts scripts/lib/*.test.mjs",
|
|
7
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
8
|
+
"build:engine": "node --experimental-strip-types scripts/build-engine.mjs",
|
|
9
|
+
"check:engine": "node --experimental-strip-types scripts/build-engine.mjs --check"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React, { StrictMode } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import type { Model, NoteDef } from '../src/model.ts';
|
|
5
|
+
|
|
6
|
+
const lifecycle = vi.hoisted(() => ({
|
|
7
|
+
alive: 0, updates: [] as Model[], mounts: 0, listenerCount: 0,
|
|
8
|
+
notes: [] as (NoteDef[] | undefined)[],
|
|
9
|
+
onSelect: undefined as ((id: string | null) => void) | undefined,
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('../templates/flow-studio.lib.mjs', () => ({
|
|
12
|
+
SHELL_BODY_HTML: '<div id="viewport"></div>',
|
|
13
|
+
mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void }) => {
|
|
14
|
+
lifecycle.mounts++;
|
|
15
|
+
lifecycle.alive++;
|
|
16
|
+
lifecycle.listenerCount++;
|
|
17
|
+
lifecycle.notes.push(options.notes);
|
|
18
|
+
lifecycle.onSelect = options.onSelect;
|
|
19
|
+
return {
|
|
20
|
+
update: (model: Model) => { lifecycle.updates.push(model); return { ok: true, issues: [] }; },
|
|
21
|
+
destroy: () => { lifecycle.alive--; lifecycle.listenerCount--; },
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { FlowStudioView } from './FlowStudioView.tsx';
|
|
27
|
+
|
|
28
|
+
const model = { version: 1, columns: [], lanes: [], nodes: [], links: [], states: {} } as unknown as Model;
|
|
29
|
+
|
|
30
|
+
describe('FlowStudioView', () => {
|
|
31
|
+
it('keeps one instance and listener set through StrictMode, updates once, and cleans up', () => {
|
|
32
|
+
const globals = window as Window & { flowStudioMount?: unknown };
|
|
33
|
+
const previousMount = globals.flowStudioMount;
|
|
34
|
+
lifecycle.alive = 0;
|
|
35
|
+
lifecycle.mounts = 0;
|
|
36
|
+
lifecycle.listenerCount = 0;
|
|
37
|
+
lifecycle.updates = [];
|
|
38
|
+
lifecycle.notes = [];
|
|
39
|
+
const select = vi.fn();
|
|
40
|
+
const rendered = render(<StrictMode><FlowStudioView model={model} onSelect={select} /></StrictMode>);
|
|
41
|
+
expect(lifecycle.mounts).toBe(2);
|
|
42
|
+
expect(lifecycle.alive).toBe(1);
|
|
43
|
+
expect(lifecycle.listenerCount).toBe(1);
|
|
44
|
+
expect(globals.flowStudioMount).toBeTypeOf('function');
|
|
45
|
+
lifecycle.onSelect?.('sample');
|
|
46
|
+
expect(select).toHaveBeenCalledWith('sample');
|
|
47
|
+
rendered.rerender(<StrictMode><FlowStudioView model={structuredClone(model)} onSelect={select} /></StrictMode>);
|
|
48
|
+
expect(lifecycle.updates).toHaveLength(0);
|
|
49
|
+
const changed = { ...model, meta: { title: 'Updated', help: '', asOf: '', source: '' } } as Model;
|
|
50
|
+
rendered.rerender(<StrictMode><FlowStudioView model={changed} onSelect={select} /></StrictMode>);
|
|
51
|
+
expect(lifecycle.updates).toEqual([changed]);
|
|
52
|
+
expect(lifecycle.alive).toBe(1);
|
|
53
|
+
rendered.unmount();
|
|
54
|
+
expect(lifecycle.alive).toBe(0);
|
|
55
|
+
expect(lifecycle.listenerCount).toBe(0);
|
|
56
|
+
expect(globals.flowStudioMount).toBe(previousMount);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('passes notes at mount and ignores later notes-only changes', () => {
|
|
60
|
+
lifecycle.updates = [];
|
|
61
|
+
lifecycle.notes = [];
|
|
62
|
+
const first = [{ anchor: 'a', title: 'First', text: 'Mount-time note' }] satisfies NoteDef[];
|
|
63
|
+
const second = [{ anchor: 'b', title: 'Second', text: 'Changed note' }] satisfies NoteDef[];
|
|
64
|
+
const rendered = render(<FlowStudioView model={model} notes={first} />);
|
|
65
|
+
expect(lifecycle.notes).toEqual([first]);
|
|
66
|
+
rendered.rerender(<FlowStudioView model={model} notes={second} />);
|
|
67
|
+
expect(lifecycle.updates).toHaveLength(0);
|
|
68
|
+
expect(lifecycle.notes).toEqual([first]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps window.flowStudioMount while any instance is mounted', () => {
|
|
72
|
+
const globals = window as Window & { flowStudioMount?: unknown };
|
|
73
|
+
const previous = globals.flowStudioMount;
|
|
74
|
+
const first = render(<FlowStudioView model={model} />);
|
|
75
|
+
const second = render(<FlowStudioView model={model} />);
|
|
76
|
+
expect(globals.flowStudioMount).toBeTypeOf('function');
|
|
77
|
+
first.unmount();
|
|
78
|
+
expect(globals.flowStudioMount).toBeTypeOf('function');
|
|
79
|
+
second.unmount();
|
|
80
|
+
expect(globals.flowStudioMount).toBe(previous);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { mount, SHELL_BODY_HTML, type FlowStudio } from "../templates/flow-studio.lib.mjs";
|
|
3
|
+
import type { Model, NoteDef } from "../src/model.ts";
|
|
4
|
+
|
|
5
|
+
export interface FlowStudioViewProps {
|
|
6
|
+
model: Model;
|
|
7
|
+
/** Read once at mount; later changes to this prop are ignored. For notes that change with the data, put them on `model.notes` — `update` forwards them. */
|
|
8
|
+
notes?: NoteDef[];
|
|
9
|
+
onSelect?: (id: string | null) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The engine publishes its mount helper on a shared global. Count mounted instances so the first
|
|
14
|
+
// mount claims the global and the last unmount releases it; unmounting one of several views must
|
|
15
|
+
// not clear the global while the others still run.
|
|
16
|
+
let mountedViews = 0;
|
|
17
|
+
let previousGlobalMount: typeof mount | undefined;
|
|
18
|
+
|
|
19
|
+
/** Mounts the finished engine in React. The shell stays outside React's child tree. */
|
|
20
|
+
export function FlowStudioView({ model, notes, onSelect, className }: FlowStudioViewProps) {
|
|
21
|
+
const root = useRef<HTMLDivElement>(null);
|
|
22
|
+
const api = useRef<FlowStudio | null>(null);
|
|
23
|
+
const currentModel = useRef(model);
|
|
24
|
+
const currentNotes = useRef(notes);
|
|
25
|
+
const currentSelect = useRef(onSelect);
|
|
26
|
+
const lastModel = useRef<string | null>(null);
|
|
27
|
+
currentModel.current = model;
|
|
28
|
+
currentNotes.current = notes;
|
|
29
|
+
currentSelect.current = onSelect;
|
|
30
|
+
const modelJson = JSON.stringify(model);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const container = root.current;
|
|
34
|
+
if (!container) return;
|
|
35
|
+
container.innerHTML = SHELL_BODY_HTML;
|
|
36
|
+
const instance = mount(container, currentModel.current, {
|
|
37
|
+
notes: currentNotes.current,
|
|
38
|
+
onSelect: id => currentSelect.current?.(id),
|
|
39
|
+
});
|
|
40
|
+
if (mountedViews === 0) {
|
|
41
|
+
previousGlobalMount = window.flowStudioMount;
|
|
42
|
+
window.flowStudioMount = mount;
|
|
43
|
+
}
|
|
44
|
+
mountedViews++;
|
|
45
|
+
api.current = instance;
|
|
46
|
+
lastModel.current = JSON.stringify(currentModel.current);
|
|
47
|
+
return () => {
|
|
48
|
+
instance.destroy();
|
|
49
|
+
if (api.current === instance) api.current = null;
|
|
50
|
+
lastModel.current = null;
|
|
51
|
+
mountedViews--;
|
|
52
|
+
if (mountedViews === 0) {
|
|
53
|
+
if (previousGlobalMount) window.flowStudioMount = previousGlobalMount;
|
|
54
|
+
else delete window.flowStudioMount;
|
|
55
|
+
previousGlobalMount = undefined;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!api.current || lastModel.current === modelJson) return;
|
|
62
|
+
api.current.update(model);
|
|
63
|
+
lastModel.current = modelJson;
|
|
64
|
+
}, [modelJson, model]);
|
|
65
|
+
|
|
66
|
+
return <div ref={root} className={className} />;
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Flow studio in a React app
|
|
2
|
+
|
|
3
|
+
## Choose a route
|
|
4
|
+
|
|
5
|
+
Use `FlowStudioView` when the page may use the engine's own look. It includes the complete canvas, inspector, notes, playback, search, and live update behavior. Use `native/` when the page must use the app's own components and design system. The native files are small examples built from the same pure source modules.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Copy `react/`, `src/`, `templates/flow-studio.lib.mjs`, `templates/flow-studio.lib.d.mts`, and `templates/flow-studio.css` into the same relative layout in your app. Install React 18 or later. The native route needs only the pure modules under `src/`; the fast route uses the bundled library. Import `flow-studio.css` once for the fast route. Import `react/native/native.css` for the native example only.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { FlowStudioView, FlowLoading, FlowEmpty, FlowError, type Model } from './react/index.ts';
|
|
13
|
+
import './templates/flow-studio.css';
|
|
14
|
+
|
|
15
|
+
type State = { kind: 'loading' } | { kind: 'ready'; model: Model; refreshError?: string } | { kind: 'error'; message: string };
|
|
16
|
+
function FlowPage({ state, onRetry, onSelect }: { state: State; onRetry: () => void; onSelect: (id: string | null) => void }) {
|
|
17
|
+
if (state.kind === 'loading') return <FlowLoading />;
|
|
18
|
+
if (state.kind === 'error') return <FlowError message={state.message} onRetry={onRetry} />;
|
|
19
|
+
if (state.model.nodes.length === 0) return <FlowEmpty model={state.model} />;
|
|
20
|
+
return <>{state.refreshError && <FlowError message={state.refreshError} onRetry={onRetry} />}
|
|
21
|
+
<FlowStudioView model={state.model} onSelect={onSelect} /></>;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
|
|
26
|
+
|
|
27
|
+
## Live data contract
|
|
28
|
+
|
|
29
|
+
Map source rows to `Model` with stable node and link ids. Derive each id from a durable business key. Never derive ids from array positions or current sort order. Keep missing metrics typed as missing. Do not turn them into zero. Show `FlowLoading` before the first model, `FlowEmpty` when the model has no nodes, and `FlowError` for a request failure.
|
|
30
|
+
|
|
31
|
+
An update that fails model validation keeps the last good graph and shows a message in the engine's `#banner`. Keep the last good model in app state when a data request fails. The engine preserves the view and surviving selection on a valid model update.
|
|
32
|
+
|
|
33
|
+
## Verify
|
|
34
|
+
|
|
35
|
+
From this skill folder, run `node scripts/react-proof.mjs`. It creates a disposable Vite app in the system temp folder, installs its test tools, runs Vitest and TypeScript, builds the app, starts a preview server, and runs the browser checks. It removes the app afterward. On Windows it uses the installed Edge browser; elsewhere it lets npm download Playwright's own browser. Your app can run the same browser checks with `node scripts/check.mjs http://localhost:5173/ --skip hostile`. Omit `--skip hostile` when you also provide a hostile test page.
|
|
36
|
+
|
|
37
|
+
For step by step integration and failure handling, see [`implement-in-app.md`](../references/implement-in-app.md).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Model, NodeDef } from "../../src/model.ts";
|
|
3
|
+
import { defaultView } from "../../src/rules.ts";
|
|
4
|
+
import { elbow, loopBack } from "../../src/routing.ts";
|
|
5
|
+
import { useFlowGeometry } from "./useFlowGeometry.ts";
|
|
6
|
+
import { useTrace } from "./useTrace.ts";
|
|
7
|
+
import { useViewport } from "./useViewport.ts";
|
|
8
|
+
import { Inspector } from "./Inspector.tsx";
|
|
9
|
+
import "./native.css";
|
|
10
|
+
|
|
11
|
+
export interface FlowCanvasProps {
|
|
12
|
+
model: Model;
|
|
13
|
+
renderNode: (node: NodeDef, context: { selected: boolean; connected: boolean }) => ReactNode;
|
|
14
|
+
mode?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Small native example. Apps can replace every element while retaining the hooks. */
|
|
19
|
+
export function FlowCanvas({ model: input, renderNode, mode, className }: FlowCanvasProps) {
|
|
20
|
+
const activeMode = mode ?? defaultView(input);
|
|
21
|
+
const trace = useTrace(input, activeMode);
|
|
22
|
+
const { model, geometry } = useFlowGeometry(input, { x: 0, y: 0, k: 1 }, trace.collapsed, activeMode);
|
|
23
|
+
const viewport = useViewport(geometry.bounds);
|
|
24
|
+
const selected = model.nodes.find(node => node.id === trace.selected) ?? null;
|
|
25
|
+
const path = (link: typeof model.links[number]) => {
|
|
26
|
+
const from = geometry.nodes[link.from], to = geometry.nodes[link.to];
|
|
27
|
+
if (!from || !to) return "";
|
|
28
|
+
if (link.kind === "loop-back") return loopBack(from.x + from.w / 2, from.y + from.h, to.x + to.w / 2, to.y + to.h, geometry.loops[link.id]?.channelY ?? Math.max(from.y + from.h, to.y + to.h) + 20);
|
|
29
|
+
return elbow(from.x + from.w, from.y + from.h / 2, to.x, to.y + to.h / 2, (from.x + from.w + to.x) / 2);
|
|
30
|
+
};
|
|
31
|
+
return <section id="workspace" className={`flow-native ${className ?? ""}`}>
|
|
32
|
+
<div id="tools" className="flow-native-tools">
|
|
33
|
+
<button type="button" onClick={() => viewport.fit()} aria-label="Fit graph">Fit</button>
|
|
34
|
+
<button type="button" onClick={() => viewport.zoomAboutCenter(1.2)} aria-label="Zoom in">+</button>
|
|
35
|
+
<button type="button" onClick={() => viewport.zoomAboutCenter(1 / 1.2)} aria-label="Zoom out">−</button>
|
|
36
|
+
<span id="zoomLabel">{Math.round(viewport.view.k * 100)}%</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div id="cols" className="flow-native-cols" aria-label="Columns">{model.columns.map((column, i) => <span key={column.id} style={{ left: (geometry.xs[i] ?? 0) * viewport.view.k + viewport.view.x, width: column.width * viewport.view.k }}>{column.title}</span>)}</div>
|
|
39
|
+
<div id="viewport" className="flow-native-viewport" {...viewport.bind}>
|
|
40
|
+
<div id="world" className="flow-native-world" style={{ width: geometry.world.w, height: geometry.world.h, transform: `translate(${viewport.view.x}px, ${viewport.view.y}px) scale(${viewport.view.k})` }}>
|
|
41
|
+
{model.lanes.map(lane => { const rect = geometry.lanes[lane.id]; return rect && <div key={lane.id} className="flow-native-lane" style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}><strong>{lane.title}</strong></div>; })}
|
|
42
|
+
<svg id="edges" className="flow-native-edges" width={geometry.world.w} height={geometry.world.h} aria-label="Links"><g id="edgeLayer">{trace.links.map(link => <path key={link.id} data-link-id={link.id} d={path(link)} fill="none" stroke="currentColor" strokeWidth="2" />)}</g></svg>
|
|
43
|
+
{model.nodes.filter(node => trace.visible.has(node.id)).map(node => {
|
|
44
|
+
const rect = geometry.nodes[node.id]; if (!rect) return null;
|
|
45
|
+
return <div key={node.id} id={`n_${node.id}`} className="flow-native-node" style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}>
|
|
46
|
+
<button type="button" className="flow-native-node-main" aria-label={node.title} aria-pressed={trace.selected === node.id} data-connected={trace.connected.has(node.id)}
|
|
47
|
+
onMouseEnter={() => trace.setHover(node.id)} onMouseLeave={() => trace.setHover(null)} onFocus={() => trace.setHover(node.id)} onBlur={() => trace.setHover(null)}
|
|
48
|
+
onClick={() => trace.setSelected(node.id)}>{renderNode(node, { selected: trace.selected === node.id, connected: trace.connected.has(node.id) })}</button>
|
|
49
|
+
{node.kind === "group" && node.contains?.length ? <button type="button" className="flow-native-toggle" aria-label={`${trace.collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.title}`}
|
|
50
|
+
onClick={() => trace.toggleGroup(node.id)}>{trace.collapsed.has(node.id) ? "+" : "−"}</button> : null}
|
|
51
|
+
</div>;
|
|
52
|
+
})}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div id="live" className="flow-native-live" aria-live="polite">{selected ? `${selected.title} selected` : ""}</div>
|
|
56
|
+
<Inspector model={model} node={selected} mode={activeMode} upstream={trace.upstream} downstream={trace.downstream} onClose={() => trace.setSelected(null)} onSelect={trace.setSelected} />
|
|
57
|
+
</section>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Model, NodeDef } from "../../src/model.ts";
|
|
2
|
+
import { bucketOf, nodeStateId } from "../../src/rules.ts";
|
|
3
|
+
|
|
4
|
+
export function Inspector({ model, node, mode, upstream, downstream, onClose, onSelect }: {
|
|
5
|
+
model: Model; node: NodeDef | null; mode: string; upstream: Set<string>; downstream: Set<string>;
|
|
6
|
+
onClose: () => void; onSelect?: (id: string) => void;
|
|
7
|
+
}) {
|
|
8
|
+
if (!node) return null;
|
|
9
|
+
const state = model.states[nodeStateId(model, mode, node)];
|
|
10
|
+
return <aside id="inspector" className="insp open" role="dialog" aria-label={`${node.title} details`} aria-modal="false">
|
|
11
|
+
<div className="insp-h"><div><h2>{node.title}</h2><small>{state?.word ?? "Unknown state"}</small></div><button type="button" onClick={onClose} aria-label="Close inspector">×</button></div>
|
|
12
|
+
<div className="insp-b">
|
|
13
|
+
{node.subtitle && <p>{node.subtitle}</p>}
|
|
14
|
+
<p>State: {bucketOf(model, nodeStateId(model, mode, node))}</p>
|
|
15
|
+
<div className="counts">
|
|
16
|
+
<button type="button" className="cbtn" aria-label={`${Math.max(0, upstream.size - 1)} upstream nodes`}>Upstream {Math.max(0, upstream.size - 1)}</button>
|
|
17
|
+
<button type="button" className="cbtn" aria-label={`${Math.max(0, downstream.size - 1)} downstream nodes`}>Downstream {Math.max(0, downstream.size - 1)}</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div className="drill" aria-label="Connected nodes">
|
|
20
|
+
{[...new Set([...upstream, ...downstream])].filter(id => id !== node.id).map(id => {
|
|
21
|
+
const related = model.nodes.find(item => item.id === id);
|
|
22
|
+
return related && <button type="button" key={id} onClick={() => onSelect?.(id)}>{related.title}</button>;
|
|
23
|
+
})}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</aside>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useViewport } from "./useViewport.ts";
|
|
2
|
+
export { useFlowGeometry } from "./useFlowGeometry.ts";
|
|
3
|
+
export { useTrace } from "./useTrace.ts";
|
|
4
|
+
export { FlowCanvas } from "./FlowCanvas.tsx";
|
|
5
|
+
export { Inspector } from "./Inspector.tsx";
|
|
6
|
+
export type { FlowCanvasProps } from "./FlowCanvas.tsx";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.flow-native { position: relative; min-height: 460px; overflow: hidden; }
|
|
2
|
+
.flow-native-tools { display: flex; gap: .5rem; align-items: center; padding: .5rem; }
|
|
3
|
+
.flow-native-cols { position: relative; height: 2.5rem; overflow: hidden; }
|
|
4
|
+
.flow-native-cols span { position: absolute; overflow: hidden; white-space: nowrap; }
|
|
5
|
+
.flow-native-viewport { position: relative; height: 440px; overflow: hidden; touch-action: none; outline-offset: -2px; }
|
|
6
|
+
.flow-native-world { position: absolute; transform-origin: 0 0; }
|
|
7
|
+
.flow-native-lane, .flow-native-node, .flow-native-edges { position: absolute; }
|
|
8
|
+
.flow-native-lane { border: 1px solid currentColor; opacity: .45; pointer-events: none; }
|
|
9
|
+
.flow-native-lane strong { display: block; padding: .3rem; }
|
|
10
|
+
.flow-native-node { display: flex; text-align: left; background: inherit; color: inherit; border: 1px solid currentColor; }
|
|
11
|
+
.flow-native-node-main { flex: 1; min-width: 0; padding: .4rem; background: transparent; color: inherit; border: 0; text-align: left; }
|
|
12
|
+
.flow-native-node-main[aria-pressed="true"] { outline: 2px solid currentColor; }
|
|
13
|
+
.flow-native-toggle { align-self: start; padding: 0 .35rem; cursor: pointer; }
|
|
14
|
+
.flow-native .insp { position: absolute; top: 0; right: 0; bottom: 0; width: min(320px, 90%); background: Canvas; color: CanvasText; border-left: 1px solid; overflow: auto; }
|
|
15
|
+
.flow-native .insp-h { display: flex; justify-content: space-between; padding: 1rem; }
|
|
16
|
+
.flow-native .insp-b { padding: 1rem; }
|
|
17
|
+
.flow-native .counts, .flow-native .drill { display: flex; gap: .5rem; flex-wrap: wrap; }
|
|
18
|
+
.flow-native-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
if (typeof window !== "undefined" && typeof window.PointerEvent === "undefined") {
|
|
2
|
+
class PointerEvent extends MouseEvent {
|
|
3
|
+
pointerId: number;
|
|
4
|
+
pointerType: string;
|
|
5
|
+
constructor(type: string, params: PointerEventInit = {}) {
|
|
6
|
+
super(type, params);
|
|
7
|
+
this.pointerId = params.pointerId ?? 0;
|
|
8
|
+
this.pointerType = params.pointerType ?? "mouse";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
window.PointerEvent = PointerEvent as unknown as typeof window.PointerEvent;
|
|
12
|
+
}
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
16
|
+
import type { Model } from "../../src/model.ts";
|
|
17
|
+
import { useViewport } from "./useViewport.ts";
|
|
18
|
+
import { useFlowGeometry } from "./useFlowGeometry.ts";
|
|
19
|
+
import { FlowCanvas } from "./FlowCanvas.tsx";
|
|
20
|
+
|
|
21
|
+
const model: Model = {
|
|
22
|
+
version: 1, meta: { title: "Sample", help: "", asOf: "", source: "" }, layout: "columns-lanes",
|
|
23
|
+
columns: [{ id: "one", title: "One", width: 180 }, { id: "two", title: "Two", width: 180 }],
|
|
24
|
+
lanes: [{ id: "lane", title: "Lane" }],
|
|
25
|
+
states: { good: { word: "Ready", tone: "ok", border: "solid", edge: "flow", motion: "none", bucket: "good" } },
|
|
26
|
+
nodes: [
|
|
27
|
+
{ id: "a", kind: "card", lane: "lane", col: 0, row: 0, state: "good", title: "Alpha" },
|
|
28
|
+
{ id: "b", kind: "card", lane: "lane", col: 1, row: 0, state: "good", title: "Beta" },
|
|
29
|
+
], links: [{ id: "ab", from: "a", to: "b", state: "good" }],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function ViewProbe() {
|
|
33
|
+
const { view, bind, fit, zoomBy, zoomAboutCenter } = useViewport({ x: 0, y: 0, w: 1000, h: 500 });
|
|
34
|
+
return <>
|
|
35
|
+
<div data-testid="pad" {...bind} />
|
|
36
|
+
<output data-testid="view">{JSON.stringify(view)}</output>
|
|
37
|
+
<button onClick={() => zoomBy(2, 100, 80)}>Zoom at point</button>
|
|
38
|
+
<button onClick={() => zoomAboutCenter(2)}>Zoom about center</button>
|
|
39
|
+
<button onClick={() => fit()}>Fit measured</button>
|
|
40
|
+
<button onClick={() => fit({ w: 500, h: 300 })}>Fit explicit</button>
|
|
41
|
+
</>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const view = () => JSON.parse(screen.getByTestId("view").textContent!) as { x: number; y: number; k: number };
|
|
45
|
+
/** jsdom reports 0 for every box; the hook must read the sizes from the attached element. */
|
|
46
|
+
const measure = (element: Element, w: number, h: number) => {
|
|
47
|
+
Object.defineProperty(element, "clientWidth", { value: w, configurable: true });
|
|
48
|
+
Object.defineProperty(element, "clientHeight", { value: h, configurable: true });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
afterEach(cleanup);
|
|
52
|
+
|
|
53
|
+
describe("native flow viewport", () => {
|
|
54
|
+
it("keeps the point below the cursor during zoom and fits given bounds", () => {
|
|
55
|
+
render(<ViewProbe />);
|
|
56
|
+
fireEvent.click(screen.getByText("Zoom at point"));
|
|
57
|
+
expect(view()).toEqual({ x: -100, y: -80, k: 2 });
|
|
58
|
+
fireEvent.click(screen.getByText("Fit explicit"));
|
|
59
|
+
expect(view().k).toBeLessThan(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("measures the attached element when fit() gets no size", () => {
|
|
63
|
+
render(<ViewProbe />);
|
|
64
|
+
measure(screen.getByTestId("pad"), 800, 400);
|
|
65
|
+
fireEvent.click(screen.getByText("Fit measured"));
|
|
66
|
+
expect(view().k).toBeCloseTo(0.616, 5);
|
|
67
|
+
expect(view().x).toBeCloseTo(92, 5);
|
|
68
|
+
expect(view().y).toBeCloseTo(68, 5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("zooms from a native non-passive wheel listener that can block page scroll", () => {
|
|
72
|
+
render(<ViewProbe />);
|
|
73
|
+
const pad = screen.getByTestId("pad");
|
|
74
|
+
const wheel = new WheelEvent("wheel", { deltaY: -120, clientX: 60, clientY: 40, cancelable: true, bubbles: true });
|
|
75
|
+
fireEvent(pad, wheel);
|
|
76
|
+
expect(wheel.defaultPrevented).toBe(true);
|
|
77
|
+
expect(view().k).toBeGreaterThan(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("pans with a pointer drag and stops tracking after pointer up", () => {
|
|
81
|
+
render(<ViewProbe />);
|
|
82
|
+
const pad = screen.getByTestId("pad");
|
|
83
|
+
// jsdom has no real pointer capture; the pan maths must not depend on it.
|
|
84
|
+
if (typeof pad.setPointerCapture === "function") pad.setPointerCapture = () => {};
|
|
85
|
+
if (typeof pad.releasePointerCapture === "function") pad.releasePointerCapture = () => {};
|
|
86
|
+
measure(pad, 800, 400);
|
|
87
|
+
fireEvent.pointerDown(pad, { button: 0, pointerId: 1, clientX: 100, clientY: 100 });
|
|
88
|
+
fireEvent.pointerMove(pad, { pointerId: 1, clientX: 150, clientY: 120 });
|
|
89
|
+
expect(view()).toEqual({ x: 50, y: 20, k: 1 });
|
|
90
|
+
fireEvent.pointerUp(pad, { pointerId: 1 });
|
|
91
|
+
fireEvent.pointerMove(pad, { pointerId: 1, clientX: 400, clientY: 300 });
|
|
92
|
+
expect(view()).toEqual({ x: 50, y: 20, k: 1 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("zooms with the keyboard about the measured center and fits with 0", () => {
|
|
96
|
+
render(<ViewProbe />);
|
|
97
|
+
const pad = screen.getByTestId("pad");
|
|
98
|
+
measure(pad, 800, 400);
|
|
99
|
+
fireEvent.keyDown(pad, { key: "+" });
|
|
100
|
+
expect(view()).toEqual({ x: -80, y: -40, k: 1.2 });
|
|
101
|
+
fireEvent.keyDown(pad, { key: "-" });
|
|
102
|
+
expect(view().k).toBeCloseTo(1, 10);
|
|
103
|
+
fireEvent.keyDown(pad, { key: "0" });
|
|
104
|
+
expect(view().k).toBeCloseTo(0.616, 5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("zoomAboutCenter keeps the middle of the viewport fixed", () => {
|
|
108
|
+
render(<ViewProbe />);
|
|
109
|
+
measure(screen.getByTestId("pad"), 800, 400);
|
|
110
|
+
fireEvent.click(screen.getByText("Zoom about center"));
|
|
111
|
+
expect(view()).toEqual({ x: -400, y: -200, k: 2 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reuses the geometry across renders when the collapsed default is omitted", () => {
|
|
115
|
+
const seen: unknown[] = [];
|
|
116
|
+
function GeometryProbe() {
|
|
117
|
+
const { geometry } = useFlowGeometry(model, { x: 0, y: 0, k: 1 });
|
|
118
|
+
seen.push(geometry);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const rendered = render(<GeometryProbe />);
|
|
122
|
+
rendered.rerender(<GeometryProbe />);
|
|
123
|
+
rendered.rerender(<GeometryProbe />);
|
|
124
|
+
expect(seen[1]).toBe(seen[0]);
|
|
125
|
+
expect(seen[2]).toBe(seen[0]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("native flow canvas", () => {
|
|
130
|
+
it("selects a node, traces its link, and closes the inspector with Escape", () => {
|
|
131
|
+
render(<FlowCanvas model={model} renderNode={node => <span>{node.title}</span>} />);
|
|
132
|
+
fireEvent.click(screen.getByRole("button", { name: "Alpha" }));
|
|
133
|
+
expect(screen.getByRole("dialog").textContent).toContain("Alpha");
|
|
134
|
+
expect(document.querySelector("#n_b button")?.getAttribute("data-connected")).toBe("true");
|
|
135
|
+
expect(screen.getByRole("button", { name: "0 upstream nodes" })).toBeTruthy();
|
|
136
|
+
expect(screen.getByRole("button", { name: "1 downstream nodes" })).toBeTruthy();
|
|
137
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
138
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("hides contained nodes while their group is collapsed", () => {
|
|
142
|
+
const grouped: Model = { ...model, nodes: [
|
|
143
|
+
{ id: "g", kind: "group", lane: "lane", col: 0, row: 0, state: "good", title: "Group", contains: ["a"] },
|
|
144
|
+
{ ...model.nodes[0]!, row: 1 }, model.nodes[1]!,
|
|
145
|
+
] };
|
|
146
|
+
render(<FlowCanvas model={grouped} renderNode={node => <span>{node.title}</span>} />);
|
|
147
|
+
expect(screen.queryByRole("button", { name: "Alpha" })).toBeNull();
|
|
148
|
+
fireEvent.click(screen.getByRole("button", { name: "Expand Group" }));
|
|
149
|
+
expect(screen.getByRole("button", { name: "Alpha" })).toBeTruthy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("zooms the tool buttons about the viewport center, not a fixed point", () => {
|
|
153
|
+
render(<FlowCanvas model={model} renderNode={node => <span>{node.title}</span>} />);
|
|
154
|
+
measure(document.getElementById("viewport")!, 800, 400);
|
|
155
|
+
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
156
|
+
expect(document.getElementById("world")!.style.transform).toMatch(/translate\(-80px, -40px\) scale\(1\.2\)/);
|
|
157
|
+
expect(screen.getByText("120%")).toBeTruthy();
|
|
158
|
+
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
159
|
+
expect(screen.getByText("100%")).toBeTruthy();
|
|
160
|
+
});
|
|
161
|
+
});
|