@caddis/cli 0.4.43 → 0.4.45

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 (111) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +48 -7
  3. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +27 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -5
  5. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  6. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  7. package/bundles/antigravity-plugin/skills/_registry.md +5 -4
  8. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +6 -1
  9. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +7 -1
  10. package/bundles/antigravity-plugin/skills/decide/SKILL.md +1 -1
  11. package/bundles/antigravity-plugin/skills/flow-studio/README.md +53 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +103 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +11 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +123 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +79 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +55 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +37 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +388 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +64 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +38 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +171 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +231 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +118 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +142 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +108 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +240 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +69 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +57 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +107 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +165 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +92 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +155 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +13 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +11 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +88 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +35 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +66 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +44 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +157 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +20 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +22 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +1744 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +61 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +8 -0
  55. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +6 -1
  56. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +6 -1
  57. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +5 -6
  58. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  59. package/bundles/antigravity-plugin-extras/skills/_registry.md +2 -0
  60. package/bundles/antigravity-plugin-extras/skills/adopt-rbac/SKILL.md +26 -0
  61. package/bundles/antigravity-plugin-extras/skills/case-management/SKILL.md +24 -0
  62. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +6 -1
  63. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +6 -1
  64. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +5 -6
  65. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +53 -0
  66. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +103 -0
  67. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +11 -0
  68. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  69. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  70. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +123 -0
  71. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  72. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +79 -0
  73. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +55 -0
  74. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +37 -0
  75. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +388 -0
  76. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  77. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  78. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +64 -0
  79. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  80. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  81. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +38 -0
  82. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +171 -0
  83. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +231 -0
  84. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +118 -0
  85. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +142 -0
  86. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +108 -0
  87. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +240 -0
  88. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +69 -0
  89. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +57 -0
  90. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +107 -0
  91. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +165 -0
  92. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +92 -0
  93. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +155 -0
  94. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +13 -0
  95. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +11 -0
  96. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +88 -0
  97. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +35 -0
  98. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +66 -0
  99. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +44 -0
  100. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +157 -0
  101. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +20 -0
  102. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +22 -0
  103. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  104. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  105. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +1744 -0
  106. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +61 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  108. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +6 -1
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +7 -1
  110. package/bundles/manifest.json +3 -3
  111. package/package.json +1 -1
@@ -0,0 +1,107 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateModel, esc, type Model } from "./model.ts";
4
+
5
+ const base = (): Model => ({
6
+ version: 1,
7
+ meta: { title: "T", help: "h", asOf: "2026-01-01", source: "s" },
8
+ layout: "columns-lanes",
9
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
10
+ lanes: [{ id: "l1", title: "Lane 1" }],
11
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
12
+ nodes: [
13
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
14
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "ok", title: "B" },
15
+ ],
16
+ links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
17
+ });
18
+ const codes = (m: unknown) => validateModel(m).filter(i => i.level === "error").map(i => i.code);
19
+
20
+ test("a valid model has no errors", () => assert.deepEqual(codes(base()), []));
21
+ test("not an object", () => assert.deepEqual(codes(null), ["model-shape"]));
22
+ test("link to a missing node names the link", () => {
23
+ const m = base(); m.links[0]!.to = "zzz";
24
+ const issues = validateModel(m);
25
+ assert.ok(issues.some(i => i.code === "link-endpoint" && i.where === "links[a-b]"));
26
+ });
27
+ test("duplicate node id", () => { const m = base(); m.nodes[1]!.id = "a"; assert.ok(codes(m).includes("node-duplicate")); });
28
+ test("undefined state on a node", () => { const m = base(); m.nodes[0]!.state = "nope"; assert.ok(codes(m).includes("state-undefined")); });
29
+ test("node in an unknown lane", () => { const m = base(); m.nodes[0]!.lane = "zz"; assert.ok(codes(m).includes("lane-undefined")); });
30
+ test("column out of range", () => { const m = base(); m.nodes[0]!.col = 5; assert.ok(codes(m).includes("column-range")); });
31
+ test("colSpan that runs past the last column", () => { const m = base(); m.nodes[1]!.colSpan = 3; assert.ok(codes(m).includes("column-range")); });
32
+ test("two nodes in the same cell overlap", () => {
33
+ const m = base(); m.nodes[1]!.col = 0; m.links = [];
34
+ assert.ok(codes(m).includes("cell-overlap"));
35
+ });
36
+ test("rows less than one apart in the same column overlap", () => {
37
+ const m = base(); m.nodes[1]!.col = 0; m.nodes[1]!.row = 0.5;
38
+ assert.ok(codes(m).includes("cell-overlap"));
39
+ });
40
+ test("an orphan node is an error unless allowed", () => {
41
+ const m = base(); m.links = [];
42
+ assert.equal(codes(m).filter(c => c === "orphan").length, 2);
43
+ m.nodes[0]!.allowIsolated = true; m.nodes[1]!.allowIsolated = true;
44
+ assert.deepEqual(codes(m), []);
45
+ });
46
+ test("a chip must point at a real node", () => {
47
+ const m = base(); m.nodes.push({ id: "c", kind: "chip", lane: "l1", col: 0, row: 1, state: "ok", title: "c", ref: "zzz" });
48
+ assert.ok(codes(m).includes("chip-ref"));
49
+ });
50
+ test("empty model is reported, not thrown", () => {
51
+ const m = base(); m.nodes = []; m.links = [];
52
+ const issues = validateModel(m);
53
+ assert.ok(issues.some(i => i.code === "model-empty"));
54
+ });
55
+ test("duplicate link id", () => { const m = base(); m.links.push({ ...m.links[0]! }); assert.ok(codes(m).includes("link-duplicate")); });
56
+ test("a state with an unknown tone is rejected", () => {
57
+ const m = base(); (m.states.ok as any).tone = "purple";
58
+ assert.ok(codes(m).includes("state-shape"));
59
+ });
60
+ test("only the columns-lanes layout exists in this stage", () => {
61
+ const m = base(); (m as any).layout = "free";
62
+ assert.ok(codes(m).includes("layout-unsupported"));
63
+ });
64
+ test("a link that goes backward or sideways is a warning, not silence", () => {
65
+ const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a";
66
+ const issues = validateModel(m);
67
+ assert.ok(issues.some(i => i.level === "warn" && i.code === "link-direction" && i.where === "links[a-b]"));
68
+ assert.deepEqual(codes(m), []);
69
+ });
70
+ test("a forward link has no direction warning", () => {
71
+ assert.ok(!validateModel(base()).some(i => i.code === "link-direction"));
72
+ });
73
+ test("a model with no meta is an error, not a crash later", () => {
74
+ const m = base(); delete (m as any).meta;
75
+ assert.ok(codes(m).includes("meta-missing"));
76
+ });
77
+ test("a node row that is not a finite number is an error (it would draw NaN)", () => {
78
+ const m = base(); (m.nodes[0] as any).row = "zero"; (m.nodes[1] as any).row = NaN;
79
+ assert.equal(codes(m).filter(c => c === "row-invalid").length, 2);
80
+ });
81
+ test("a node that is not an object is reported, never thrown (review focus 1)", () => {
82
+ const m = base(); (m.nodes as any[]).push(null, 7, "x");
83
+ assert.doesNotThrow(() => validateModel(m));
84
+ assert.equal(codes(m).filter(c => c === "node-shape").length, 3);
85
+ });
86
+ test("a link that is not an object is reported, never thrown", () => {
87
+ const m = base(); (m.links as any[]).push(null);
88
+ assert.doesNotThrow(() => validateModel(m));
89
+ assert.ok(codes(m).includes("link-shape"));
90
+ });
91
+ test("a node id that is not a string is an error", () => {
92
+ const m = base(); (m.nodes[0] as any).id = 5;
93
+ assert.ok(codes(m).includes("node-shape"));
94
+ });
95
+ test("lanes that are not an array do not make the validator throw", () => {
96
+ const m = base(); (m as any).lanes = "abc";
97
+ assert.doesNotThrow(() => validateModel(m));
98
+ assert.ok(codes(m).includes("lanes-missing"));
99
+ });
100
+ test("a state id must be usable as a CSS class", () => {
101
+ const m = base(); m.states['bad id"'] = m.states.ok!;
102
+ assert.ok(codes(m).includes("state-id"));
103
+ });
104
+ test("esc turns markup into text", () => {
105
+ assert.equal(esc(`<img src=x onerror="a()">&'`), "&lt;img src=x onerror=&quot;a()&quot;&gt;&amp;&#39;");
106
+ assert.equal(esc(undefined), "");
107
+ });
@@ -0,0 +1,165 @@
1
+ // The model: what an author writes. Pure types plus validation. No DOM.
2
+
3
+ export type Kind = "card" | "group" | "chip" | "gateway" | "start" | "end" | "event" | "placeholder" | "note";
4
+ export type Tone = "ok" | "warn" | "crit" | "info" | "muted" | "accent";
5
+ export type Border = "solid" | "dashed" | "dotted";
6
+ export type EdgeStyle = "flow" | "dash" | "dash-short" | "dot";
7
+ export type Motion = "flow" | "flow-slow" | "breathe" | "none";
8
+ export type Bucket = "good" | "neutral" | "gap";
9
+
10
+ export interface StateDef {
11
+ word: string;
12
+ tone: Tone;
13
+ border: Border;
14
+ edge: EdgeStyle;
15
+ motion: Motion;
16
+ bucket: Bucket;
17
+ }
18
+ export interface ColumnDef { id: string; title: string; short?: string; width: number; phase?: string }
19
+ export interface LaneDef { id: string; title: string; badge?: string; badgeTone?: "ok" | "warn" | "muted"; note?: string; gap?: boolean }
20
+ export interface NodeDef {
21
+ id: string;
22
+ kind: Kind;
23
+ lane: string;
24
+ col: number;
25
+ colSpan?: number;
26
+ row: number;
27
+ state: string;
28
+ stateIn?: Record<string, string>;
29
+ visibleIn?: string[];
30
+ title: string;
31
+ subtitle?: string;
32
+ members?: string[];
33
+ weight?: number;
34
+ ref?: string;
35
+ upgrade?: string;
36
+ allowIsolated?: boolean;
37
+ }
38
+ export interface LinkDef {
39
+ id: string;
40
+ from: string;
41
+ to: string;
42
+ state: string;
43
+ stateIn?: Record<string, string>;
44
+ visibleIn?: string[];
45
+ label?: string;
46
+ }
47
+ export interface ViewDef { id: string; label: string; default?: boolean }
48
+ export interface RelationDef { id: string; label: string; dir: "up" | "down"; filter?: { col?: number; kind?: Kind } }
49
+ export interface ActionDef { id: string; label: string; dir: "down"; staleText: string }
50
+ export interface NoteDef { anchor: string; title: string; text: string }
51
+ export interface Model {
52
+ version: 1;
53
+ meta: { title: string; help: string; asOf: string; source: string; labels?: Record<string, string> };
54
+ layout: "columns-lanes";
55
+ columns: ColumnDef[];
56
+ lanes: LaneDef[];
57
+ states: Record<string, StateDef>;
58
+ nodes: NodeDef[];
59
+ links: LinkDef[];
60
+ views?: ViewDef[];
61
+ inspector?: { relations: RelationDef[]; actions?: ActionDef[] };
62
+ notes?: NoteDef[];
63
+ }
64
+ export interface Issue { level: "error" | "warn"; code: string; where: string; message: string }
65
+
66
+ const TONES = ["ok", "warn", "crit", "info", "muted", "accent"];
67
+ const BORDERS = ["solid", "dashed", "dotted"];
68
+ const EDGES = ["flow", "dash", "dash-short", "dot"];
69
+ const MOTIONS = ["flow", "flow-slow", "breathe", "none"];
70
+ const BUCKETS = ["good", "neutral", "gap"];
71
+
72
+ /** Turns a value into text that is safe inside HTML. Use it for every model string put into markup. */
73
+ export function esc(s: unknown): string {
74
+ const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
75
+ return String(s ?? "").replace(/[&<>"']/g, c => map[c] as string);
76
+ }
77
+
78
+ /** Checks a model and returns every problem. Never throws. An error blocks the build; a warning does not. */
79
+ export function validateModel(input: unknown): Issue[] {
80
+ const out: Issue[] = [];
81
+ const add = (level: Issue["level"], code: string, where: string, message: string) => out.push({ level, code, where, message });
82
+ if (!input || typeof input !== "object") {
83
+ add("error", "model-shape", "model", "The model must be a JSON object.");
84
+ return out;
85
+ }
86
+ const m = input as Model;
87
+ if (m.version !== 1) add("error", "model-version", "version", "version must be 1.");
88
+ if (m.layout !== "columns-lanes") add("error", "layout-unsupported", "layout", `layout ${String(m.layout)} is not supported yet: use "columns-lanes" ("free" and "auto" arrive in a later stage).`);
89
+ if (!Array.isArray(m.columns) || m.columns.length === 0) add("error", "columns-missing", "columns", "At least one column is required.");
90
+ if (!Array.isArray(m.lanes) || m.lanes.length === 0) add("error", "lanes-missing", "lanes", "At least one lane is required.");
91
+ if (!m.states || typeof m.states !== "object" || Object.keys(m.states).length === 0) add("error", "states-missing", "states", "At least one state is required.");
92
+ const columns = Array.isArray(m.columns) ? m.columns : [];
93
+ const lanes = Array.isArray(m.lanes) ? m.lanes : [];
94
+ const states = m.states && typeof m.states === "object" ? m.states : {};
95
+ if (!m.meta || typeof m.meta !== "object" || typeof m.meta.title !== "string") add("error", "meta-missing", "meta", "meta with a title is required.");
96
+ // Entries that are not objects are reported and dropped, so no later loop can throw on them.
97
+ const nodes: NodeDef[] = [];
98
+ (Array.isArray(m.nodes) ? m.nodes : []).forEach((n, i) => {
99
+ if (!n || typeof n !== "object" || typeof (n as NodeDef).id !== "string") add("error", "node-shape", `nodes[${i}]`, "A node must be an object with a string id.");
100
+ else nodes.push(n);
101
+ });
102
+ const links: LinkDef[] = [];
103
+ (Array.isArray(m.links) ? m.links : []).forEach((l, i) => {
104
+ if (!l || typeof l !== "object" || typeof (l as LinkDef).id !== "string") add("error", "link-shape", `links[${i}]`, "A link must be an object with a string id.");
105
+ else links.push(l);
106
+ });
107
+ if (nodes.length === 0) add("warn", "model-empty", "nodes", "The model has no nodes.");
108
+
109
+ for (const [id, s] of Object.entries(states)) {
110
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) add("error", "state-id", `states[${id}]`, `State id ${id} may only use letters, digits, "_" and "-" (it becomes a CSS class).`);
111
+ const ok = s && typeof s.word === "string" && TONES.includes(s.tone) && BORDERS.includes(s.border) && EDGES.includes(s.edge) && MOTIONS.includes(s.motion) && BUCKETS.includes(s.bucket);
112
+ if (!ok) add("error", "state-shape", `states[${id}]`, `State ${id} needs word, tone, border, edge, motion and bucket with allowed values.`);
113
+ }
114
+ const laneIds = new Set(lanes.map(l => l.id));
115
+ const stateIds = new Set(Object.keys(states));
116
+ const nodeIds = new Set<string>();
117
+ const ncols = columns.length;
118
+ for (const n of nodes) {
119
+ const w = `nodes[${n.id}]`;
120
+ if (nodeIds.has(n.id)) add("error", "node-duplicate", w, `Duplicate node id ${n.id}.`);
121
+ nodeIds.add(n.id);
122
+ if (!laneIds.has(n.lane)) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
123
+ for (const s of [n.state, ...Object.values(n.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
124
+ if (typeof n.row !== "number" || !Number.isFinite(n.row)) add("error", "row-invalid", w, `Node ${n.id} needs a finite number for row (it sets the vertical position).`);
125
+ const span = n.colSpan ?? 1;
126
+ if (!Number.isInteger(n.col) || n.col < 0 || n.col + span > ncols) add("error", "column-range", w, `Column ${n.col} with span ${span} is outside 0..${ncols - 1}.`);
127
+ }
128
+ for (const n of nodes) {
129
+ if (n.kind === "chip" && (!n.ref || !nodeIds.has(n.ref))) add("error", "chip-ref", `nodes[${n.id}]`, `A chip must point at a real node (ref ${n.ref}).`);
130
+ }
131
+ const linkIds = new Set<string>();
132
+ const linked = new Set<string>();
133
+ for (const l of links) {
134
+ const w = `links[${l.id}]`;
135
+ if (linkIds.has(l.id)) add("error", "link-duplicate", w, `Duplicate link id ${l.id}.`);
136
+ linkIds.add(l.id);
137
+ if (!nodeIds.has(l.from) || !nodeIds.has(l.to)) add("error", "link-endpoint", w, `Link ${l.id} has an endpoint that is not a node (${l.from} to ${l.to}).`);
138
+ else {
139
+ linked.add(l.from); linked.add(l.to);
140
+ const a = nodes.find(n => n.id === l.from)!, b = nodes.find(n => n.id === l.to)!;
141
+ if (b.col <= a.col + (a.colSpan ?? 1) - 1) {
142
+ add("warn", "link-direction", w, `Link ${l.id} does not run left to right (column ${a.col} to ${b.col}); it will look wrong until loop-back links arrive.`);
143
+ }
144
+ }
145
+ for (const s of [l.state, ...Object.values(l.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
146
+ }
147
+ for (const n of nodes) {
148
+ if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note") {
149
+ add("error", "orphan", `nodes[${n.id}]`, `Node ${n.id} has no links. Add a link, a placeholder neighbour, or set allowIsolated.`);
150
+ }
151
+ }
152
+ // Columns and rows are global to the canvas, so two nodes clash whatever their lane.
153
+ for (let i = 0; i < nodes.length; i++) {
154
+ for (let j = i + 1; j < nodes.length; j++) {
155
+ const a = nodes[i]!, b = nodes[j]!;
156
+ const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
157
+ if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
158
+ add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
159
+ }
160
+ }
161
+ }
162
+ const used = new Set(nodes.map(n => n.lane));
163
+ for (const l of lanes) if (!used.has(l.id)) add("warn", "lane-empty", `lanes[${l.id}]`, `Lane ${l.id} has no nodes.`);
164
+ return out;
165
+ }
@@ -0,0 +1,92 @@
1
+ // Implementation notes (a left drawer with numbered markers on the page) and the model reference
2
+ // section with Copy as Markdown and Copy JSON. The notes are for the person or agent who builds the page.
3
+
4
+ import { el, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+
7
+ export function install(ctx: Ctx): void {
8
+ const { els, model } = ctx;
9
+ const notes = ctx.notes.map((n, i) => ({ id: i + 1, ...n }));
10
+
11
+ const card = (id: number) => els.notelist.querySelector<HTMLElement>(`[data-n="${id}"]`);
12
+ const anchorOf = (sel: string): Element | null => { try { return document.querySelector(sel); } catch { return null; } };
13
+
14
+ function clearMarkers(): void {
15
+ document.querySelectorAll(".marker").forEach(m => m.remove());
16
+ document.querySelectorAll(".anchored").forEach(a => a.classList.remove("anchored"));
17
+ }
18
+
19
+ function placeMarkers(): void {
20
+ clearMarkers();
21
+ for (const n of notes) {
22
+ const a = anchorOf(n.anchor);
23
+ if (!a) continue;
24
+ let r = a.getBoundingClientRect();
25
+ if (!r.width && !r.height) continue;
26
+ if (a.id === "inspector" && !a.classList.contains("open")) {
27
+ const vr = els.vp.getBoundingClientRect();
28
+ r = { left: vr.right - 130, top: vr.top + 56, right: vr.right - 128, bottom: vr.top + 58, width: 2, height: 2, x: 0, y: 0, toJSON: () => ({}) };
29
+ }
30
+ const v = els.vp.getBoundingClientRect();
31
+ if (a.closest("#world") && (r.right < v.left || r.left > v.right || r.bottom < v.top || r.top > v.bottom)) continue;
32
+ const m = el("button", "marker", String(n.id));
33
+ m.type = "button"; m.dataset.n = String(n.id); m.title = n.title;
34
+ m.setAttribute("aria-label", `Note ${n.id}: ${n.title}`);
35
+ m.style.left = r.left + scrollX - 10 + "px";
36
+ m.style.top = r.top + scrollY - 10 + "px";
37
+ m.addEventListener("mouseenter", () => { a.classList.add("anchored"); card(n.id)?.classList.add("on"); });
38
+ m.addEventListener("mouseleave", () => { a.classList.remove("anchored"); card(n.id)?.classList.remove("on"); });
39
+ m.addEventListener("click", () => card(n.id)?.scrollIntoView({ block: "center", behavior: ctx.reduced ? "auto" : "smooth" }));
40
+ document.body.appendChild(m);
41
+ }
42
+ }
43
+
44
+ function toggleNotes(next?: boolean): void {
45
+ const on_ = next === undefined ? !document.body.classList.contains("notes") : next;
46
+ document.body.classList.toggle("notes", on_);
47
+ els.notesBtn.setAttribute("aria-pressed", String(on_));
48
+ if (on_) window.setTimeout(placeMarkers, ctx.reduced ? 30 : 340);
49
+ else clearMarkers();
50
+ }
51
+
52
+ els.notelist.innerHTML = notes.map(n => `<div class="ncard" data-n="${n.id}"><b><span>${n.id}</span>${esc(n.title)}</b>${esc(n.text)}</div>`).join("")
53
+ || '<p class="note">This page has no implementation notes.</p>';
54
+ els.notelist.querySelectorAll<HTMLElement>(".ncard").forEach(c => {
55
+ c.addEventListener("mouseenter", () => { const n = notes.find(x => String(x.id) === c.dataset.n); const a = n && anchorOf(n.anchor); if (a) a.classList.add("anchored"); });
56
+ c.addEventListener("mouseleave", () => document.querySelectorAll(".anchored").forEach(a => a.classList.remove("anchored")));
57
+ });
58
+ on(ctx, els.notesBtn, "click", () => toggleNotes());
59
+ on(ctx, window, "scroll", () => { if (document.body.classList.contains("notes")) placeMarkers(); }, { passive: true });
60
+
61
+ // ---- model reference
62
+ const markdown = (): string => {
63
+ const out = [`# ${model.meta.title}`, "", model.meta.help, "", `As of ${model.meta.asOf}. Source: ${model.meta.source}`, "", "## States"];
64
+ for (const [id, s] of Object.entries(model.states)) out.push(`- **${s.word}** (${id}): tone ${s.tone}, border ${s.border}, link ${s.edge}, motion ${s.motion}, bucket ${s.bucket}`);
65
+ out.push("", "## Columns", ...model.columns.map((c, i) => `${i + 1}. ${c.title}${c.phase ? ` (${c.phase})` : ""}`));
66
+ out.push("", "## Lanes", ...model.lanes.map(l => `- ${l.title}${l.badge ? ` [${l.badge}]` : ""}${l.note ? `: ${l.note}` : ""}`));
67
+ out.push("", `## Size`, `${model.nodes.length} nodes, ${model.links.length} links.`);
68
+ if (notes.length) out.push("", "## Notes", ...notes.map(n => `${n.id}. **${n.title}**: ${n.text}`));
69
+ return out.join("\n");
70
+ };
71
+ const rows = (items: string[]) => `<ul>${items.map(i => `<li>${i}</li>`).join("")}</ul>`;
72
+ els.specbody.innerHTML = `<div class="specbtns"><button type="button" id="cpMd">Copy as Markdown</button><button type="button" id="cpJson">Copy model JSON</button><span class="note" id="cpMsg" style="margin:0"></span></div>
73
+ <h3>States</h3>${rows(Object.entries(model.states).map(([id, s]) => `<b>${esc(s.word)}</b> (<code>${esc(id)}</code>): tone ${esc(s.tone)}, border ${esc(s.border)}, link ${esc(s.edge)}, motion ${esc(s.motion)}, bucket ${esc(s.bucket)}`))}
74
+ <h3>Columns</h3>${rows(model.columns.map(c => `${esc(c.title)}${c.phase ? ` (${esc(c.phase)})` : ""}`))}
75
+ <h3>Lanes</h3>${rows(model.lanes.map(l => `${esc(l.title)}${l.badge ? ` [${esc(l.badge)}]` : ""}${l.note ? `: ${esc(l.note)}` : ""}`))}
76
+ <h3>Size</h3><p>${model.nodes.length} nodes, ${model.links.length} links.</p>`;
77
+ const msg = (t: string) => { const m = document.getElementById("cpMsg"); if (m) m.textContent = t; };
78
+ const copy = async (text: string, what: string) => {
79
+ try { await navigator.clipboard.writeText(text); msg(`${what} copied.`); }
80
+ catch {
81
+ const ta = document.createElement("textarea");
82
+ ta.value = text; document.body.appendChild(ta); ta.select();
83
+ try { document.execCommand("copy"); msg(`${what} copied.`); } catch { msg("Copy is blocked here: select the text instead."); }
84
+ ta.remove();
85
+ }
86
+ };
87
+ document.getElementById("cpMd")?.addEventListener("click", () => void copy(markdown(), "Markdown"));
88
+ document.getElementById("cpJson")?.addEventListener("click", () => void copy(JSON.stringify(model, null, 2), "JSON"));
89
+
90
+ ctx.fn.toggleNotes = toggleNotes;
91
+ ctx.fn.placeMarkers = placeMarkers;
92
+ }
@@ -0,0 +1,155 @@
1
+ // Builds the world (lanes, nodes, links) once, and paints it for the current view.
2
+ // Every model string goes through esc(), so a hostile title cannot become markup.
3
+
4
+ import { el, sv, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+ import type { NodeDef } from "./model.ts";
7
+ import { computeGeometry, DEFAULTS } from "./layout.ts";
8
+ import { elbow } from "./routing.ts";
9
+ import { cssId } from "./stateCss.ts";
10
+ import { baselineView, bucketOf, canonOf, isImproved, isNew, linkStateId, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
11
+
12
+ function nodeHtml(ctx: Ctx, n: NodeDef): string {
13
+ const m = ctx.model;
14
+ const sid = nodeStateId(m, ctx.mode, n);
15
+ const word = m.states[sid]?.word ?? sid;
16
+ const bucket = bucketOf(m, sid);
17
+ const sub = n.subtitle ?? m.columns[n.col]?.title ?? "";
18
+ const weight = Math.max(1, n.weight ?? 1);
19
+ const showWord = n.kind !== "placeholder" && n.kind !== "note";
20
+ const bar = weight > 1 && n.kind !== "chip"
21
+ ? `<div class="bar"><i class="${["good", "neutral", "gap"].includes(bucket) ? bucket : "gap"}" style="width:100%"></i></div>`
22
+ : "";
23
+ const improved = isImproved(m, ctx.mode, n) ? '<span class="cor up" title="Gap filled in this view">▲</span>' : "";
24
+ const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">↗</span>` : "";
25
+ const arrow = n.kind === "chip" ? '<span aria-hidden="true">↑ </span>' : "";
26
+ const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
27
+ return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span></div>`
28
+ + `<div class="s" title="${esc(sub)}">${lead}${esc(sub)}</div>${bar}${improved}${upgrade}`;
29
+ }
30
+
31
+ function build(ctx: Ctx): void {
32
+ const { model, els } = ctx;
33
+ ctx.geom = computeGeometry(model);
34
+ els.world.style.width = ctx.geom.world.w + "px";
35
+ els.world.style.height = ctx.geom.world.h + "px";
36
+ els.svg.setAttribute("width", String(ctx.geom.world.w));
37
+ els.svg.setAttribute("height", String(ctx.geom.world.h));
38
+ els.world.querySelectorAll(".node, .band, .elabel").forEach(n => n.remove());
39
+ els.layer.innerHTML = "";
40
+ ctx.nodeEls.clear(); ctx.laneEls.clear(); ctx.edgeEls = []; ctx.labelEls = [];
41
+
42
+ for (const l of model.lanes) {
43
+ const d = el("div", "band" + (l.gap ? " gap" : ""));
44
+ d.id = "lane_" + l.id;
45
+ const badge = l.badge ? `<span class="bd t-${["ok", "warn", "muted"].includes(l.badgeTone ?? "") ? l.badgeTone : "muted"}">${esc(l.badge)}</span>` : "";
46
+ const note = l.note ? `<span class="bn">${esc(l.note)}</span>` : "";
47
+ d.innerHTML = `<div class="bandlabel" role="button" tabindex="0" aria-label="${esc("Zoom to the lane " + l.title)}"><b>${esc(l.title)}</b>${badge}${note}</div>`;
48
+ const label = d.firstElementChild as HTMLElement;
49
+ on(ctx, label, "click", (e: Event) => { e.stopPropagation(); ctx.fn.zoomToBand(l.id); });
50
+ on(ctx, label, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter") ctx.fn.zoomToBand(l.id); });
51
+ els.world.insertBefore(d, els.svg.parentElement === els.world ? els.svg : null);
52
+ ctx.laneEls.set(l.id, d);
53
+ }
54
+
55
+ for (const n of model.nodes) {
56
+ const p = ctx.geom.nodes[n.id];
57
+ if (!p) continue;
58
+ const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
59
+ d.id = "n_" + n.id;
60
+ d.tabIndex = 0;
61
+ d.setAttribute("role", "button");
62
+ d.style.setProperty("--c", String(n.col));
63
+ d.style.left = p.x + "px"; d.style.top = p.y + "px"; d.style.width = p.w + "px"; d.style.height = p.h + "px";
64
+ on(ctx, d, "click", () => { if (ctx.moved > 4) return; ctx.fn.select(canonOf(ctx.nodes, n.id)); });
65
+ on(ctx, d, "dblclick", (e: Event) => { e.stopPropagation(); ctx.fn.select(canonOf(ctx.nodes, n.id)); ctx.fn.zoomToPath(); });
66
+ on(ctx, d, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ctx.fn.select(canonOf(ctx.nodes, n.id)); } });
67
+ on(ctx, d, "mouseenter", () => ctx.fn.trace(n.id));
68
+ on(ctx, d, "mouseleave", () => ctx.fn.trace(ctx.selected));
69
+ on(ctx, d, "focus", () => ctx.fn.trace(n.id));
70
+ on(ctx, d, "blur", () => ctx.fn.trace(ctx.selected));
71
+ els.world.appendChild(d);
72
+ ctx.nodeEls.set(n.id, d);
73
+ }
74
+
75
+ const gap = DEFAULTS.gap;
76
+ ctx.links.forEach((l, i) => {
77
+ const A = ctx.geom.nodes[l.from], B = ctx.geom.nodes[l.to];
78
+ if (!A || !B) { console.warn(`flow-studio: link ${l.id} skipped (an end is missing)`); return; }
79
+ const x1 = A.x + A.w, y1 = A.y + A.h / 2, x2 = B.x, y2 = B.y + B.h / 2, tx = x1 + gap / 2;
80
+ const path = sv("path", { d: elbow(x1, y1, x2 - 1, y2, tx), class: "edge", "data-i": String(i), "data-a": l.from, "data-b": l.to, "marker-end": "url(#arr)" }) as SVGPathElement;
81
+ els.layer.appendChild(path);
82
+ ctx.edgeEls[i] = path;
83
+ if (l.label) {
84
+ const lab = el("div", "elabel", esc(l.label));
85
+ lab.dataset.i = String(i); lab.dataset.a = l.from; lab.dataset.b = l.to;
86
+ lab.style.left = (Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
87
+ lab.style.top = (y1 + y2) / 2 + "px";
88
+ els.world.appendChild(lab);
89
+ ctx.labelEls[i] = lab;
90
+ }
91
+ });
92
+ }
93
+
94
+ function paint(ctx: Ctx, fromSwitch = false): void {
95
+ const { model } = ctx;
96
+ const mode = ctx.mode;
97
+ ctx.geom = computeGeometry(model, { visible: id => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, mode, n); } });
98
+ document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
99
+
100
+ for (const n of model.nodes) {
101
+ const d = ctx.nodeEls.get(n.id);
102
+ if (!d) continue;
103
+ const sid = nodeStateId(model, mode, n);
104
+ const show = nodeVisible(model, mode, n);
105
+ const bucket = bucketOf(model, sid);
106
+ const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
107
+ const fresh = isNew(model, mode, n);
108
+ d.className = "node st-" + cssId(sid)
109
+ + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
110
+ + (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? (cors > 1 ? " hascor2" : " hascor") : "")
111
+ + (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "");
112
+ d.innerHTML = nodeHtml(ctx, n);
113
+ d.setAttribute("aria-hidden", show ? "false" : "true");
114
+ d.tabIndex = show ? 0 : -1;
115
+ const word = model.states[sid]?.word ?? sid;
116
+ const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
117
+ d.setAttribute("aria-label", n.kind === "chip"
118
+ ? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.`
119
+ : `${n.title}. ${n.subtitle ?? model.columns[n.col]?.title ?? ""}, ${word}. Press Enter to inspect.`);
120
+ if (fromSwitch && fresh && !ctx.reduced) {
121
+ d.style.transitionDelay = n.col * 60 + "ms";
122
+ d.classList.add("pulse");
123
+ window.setTimeout(() => { d.style.transitionDelay = ""; d.classList.remove("pulse"); }, 2200);
124
+ } else {
125
+ d.style.transitionDelay = "";
126
+ }
127
+ }
128
+
129
+ ctx.links.forEach((l, i) => {
130
+ const p = ctx.edgeEls[i];
131
+ if (!p) return;
132
+ const vis = linkVisible(model, mode, l, ctx.nodes);
133
+ p.setAttribute("class", "edge st-" + cssId(linkStateId(model, mode, l)) + (vis ? "" : " off"));
134
+ ctx.labelEls[i]?.classList.toggle("off", !vis);
135
+ });
136
+
137
+ for (const l of model.lanes) {
138
+ const d = ctx.laneEls.get(l.id);
139
+ const r = ctx.geom.lanes[l.id];
140
+ if (!d) continue;
141
+ d.classList.toggle("off", !r);
142
+ if (r) { d.style.left = r.x + "px"; d.style.top = r.y + "px"; d.style.width = r.w + "px"; d.style.height = r.h + "px"; }
143
+ }
144
+
145
+ ctx.fn.buildChrome();
146
+ ctx.fn.trace(ctx.selected);
147
+ ctx.fn.renderInspector();
148
+ ctx.fn.stickLabels();
149
+ ctx.fn.placeHeaders();
150
+ }
151
+
152
+ export function install(ctx: Ctx): void {
153
+ ctx.fn.buildWorld = () => build(ctx);
154
+ ctx.fn.paint = (fromSwitch?: boolean) => paint(ctx, fromSwitch);
155
+ }
@@ -0,0 +1,13 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { elbow } from "./routing.ts";
4
+
5
+ test("same height is a straight line", () => assert.equal(elbow(0, 50, 100, 50, 50), "M0,50 H100"));
6
+ test("a small vertical step is a gentle S curve", () => assert.equal(elbow(0, 50, 100, 60, 50), "M0,50 C50,50 50,60 100,60"));
7
+ test("a large step is an elbow with a trunk at tx and rounded corners", () => {
8
+ assert.equal(elbow(0, 50, 100, 150, 40), "M0,50 H32 Q40,50 40,58 V142 Q40,150 48,150 H100");
9
+ });
10
+ test("going up mirrors the corners", () => {
11
+ assert.equal(elbow(0, 150, 100, 50, 40), "M0,150 H32 Q40,150 40,142 V58 Q40,50 48,50 H100");
12
+ });
13
+ test("every number in a path is finite", () => assert.ok(!/NaN|Infinity/.test(elbow(0, 0, 10, 300, 5))));
@@ -0,0 +1,11 @@
1
+ // Link routing. A link leaves the right side of a node, runs to a vertical trunk at tx (half way
2
+ // across the column gap), and enters the left side of the next node. It is horizontal where it
3
+ // touches a node, so every arrowhead points right and the links of one node share one trunk.
4
+
5
+ export function elbow(x1: number, y1: number, x2: number, y2: number, tx: number): string {
6
+ const r = 8;
7
+ if (Math.abs(y2 - y1) < 1) return `M${x1},${y1} H${x2}`;
8
+ if (Math.abs(y2 - y1) < 2 * r + 2) return `M${x1},${y1} C${tx},${y1} ${tx},${y2} ${x2},${y2}`;
9
+ const dir = y2 > y1 ? 1 : -1;
10
+ return `M${x1},${y1} H${tx - r} Q${tx},${y1} ${tx},${y1 + dir * r} V${y2 - dir * r} Q${tx},${y2} ${tx + r},${y2} H${x2}`;
11
+ }
@@ -0,0 +1,88 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { Model } from "./model.ts";
4
+ import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf } from "./rules.ts";
5
+
6
+ const m = (): Model => ({
7
+ version: 1,
8
+ meta: { title: "t", help: "", asOf: "", source: "" },
9
+ layout: "columns-lanes",
10
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
11
+ lanes: [{ id: "l1", title: "L" }],
12
+ states: {
13
+ good: { word: "GOOD", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" },
14
+ gap: { word: "GAP", tone: "warn", border: "dotted", edge: "dot", motion: "breathe", bucket: "gap" },
15
+ },
16
+ views: [{ id: "today", label: "Today" }, { id: "full", label: "Full", default: true }],
17
+ nodes: [
18
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "good", title: "A" },
19
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "good", stateIn: { today: "gap" }, title: "B" },
20
+ { id: "c", kind: "card", lane: "l1", col: 1, row: 1.8, state: "good", visibleIn: ["full"], title: "C" },
21
+ { id: "k", kind: "chip", lane: "l1", col: 0, row: 3.6, state: "good", ref: "a", title: "k" },
22
+ ],
23
+ links: [
24
+ { id: "a-b", from: "a", to: "b", state: "good" },
25
+ { id: "a-c", from: "a", to: "c", state: "good", stateIn: { today: "gap" } },
26
+ { id: "only-today", from: "a", to: "b", state: "gap", visibleIn: ["today"] },
27
+ ],
28
+ });
29
+ const byId = (mm: Model) => new Map(mm.nodes.map(n => [n.id, n]));
30
+
31
+ test("the baseline is the first view and the default is the marked one", () => {
32
+ assert.equal(baselineView(m()), "today");
33
+ assert.equal(defaultView(m()), "full");
34
+ });
35
+ test("a model with no views has one implicit view", () => {
36
+ const mm = m(); delete mm.views;
37
+ assert.equal(baselineView(mm), "all");
38
+ assert.equal(defaultView(mm), "all");
39
+ assert.equal(nodeVisible(mm, "all", mm.nodes[0]!), true);
40
+ });
41
+ test("visibleIn hides a node in other views", () => {
42
+ const mm = m();
43
+ assert.equal(nodeVisible(mm, "today", mm.nodes[2]!), false);
44
+ assert.equal(nodeVisible(mm, "full", mm.nodes[2]!), true);
45
+ });
46
+ test("stateIn overrides the state in that view only", () => {
47
+ const mm = m();
48
+ assert.equal(nodeStateId(mm, "today", mm.nodes[1]!), "gap");
49
+ assert.equal(nodeStateId(mm, "full", mm.nodes[1]!), "good");
50
+ });
51
+ test("a link is hidden when an end is hidden", () => {
52
+ const mm = m(), nodes = byId(mm);
53
+ assert.equal(linkVisible(mm, "today", mm.links[1]!, nodes), false);
54
+ assert.equal(linkVisible(mm, "full", mm.links[1]!, nodes), true);
55
+ });
56
+ test("visibleIn on a link restricts it to those views", () => {
57
+ const mm = m(), nodes = byId(mm);
58
+ assert.equal(linkVisible(mm, "today", mm.links[2]!, nodes), true);
59
+ assert.equal(linkVisible(mm, "full", mm.links[2]!, nodes), false);
60
+ });
61
+ test("a link state can differ per view", () => {
62
+ const mm = m();
63
+ assert.equal(linkStateId(mm, "today", mm.links[1]!), "gap");
64
+ assert.equal(linkStateId(mm, "full", mm.links[1]!), "good");
65
+ });
66
+ test("isNew: visible now, not in the baseline view", () => {
67
+ const mm = m();
68
+ assert.equal(isNew(mm, "full", mm.nodes[2]!), true);
69
+ assert.equal(isNew(mm, "today", mm.nodes[2]!), false);
70
+ assert.equal(isNew(mm, "full", mm.nodes[0]!), false);
71
+ });
72
+ test("isImproved: a gap in the baseline that is not a gap now", () => {
73
+ const mm = m();
74
+ assert.equal(isImproved(mm, "full", mm.nodes[1]!), true);
75
+ assert.equal(isImproved(mm, "today", mm.nodes[1]!), false);
76
+ assert.equal(isImproved(mm, "full", mm.nodes[0]!), false);
77
+ });
78
+ test("bucketOf reads the bucket of a state, and an unknown state is a gap (never a good)", () => {
79
+ const mm = m();
80
+ assert.equal(bucketOf(mm, "good"), "good");
81
+ assert.equal(bucketOf(mm, "nope"), "gap");
82
+ });
83
+ test("canonOf maps a chip to its real node and leaves others alone", () => {
84
+ const mm = m(), nodes = byId(mm);
85
+ assert.equal(canonOf(nodes, "k"), "a");
86
+ assert.equal(canonOf(nodes, "b"), "b");
87
+ assert.equal(canonOf(nodes, "missing"), "missing");
88
+ });