@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,38 @@
1
+ // Validate a flow-studio model.
2
+ // node --experimental-strip-types scripts/validate.mjs <model.json>
3
+ // Exit 0: no errors (warnings are printed). Exit 1: at least one error. Exit 2: cannot read the file.
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { validateModel } from "../src/model.ts";
7
+
8
+ export function loadModel(path) {
9
+ let text;
10
+ try {
11
+ text = readFileSync(path, "utf8");
12
+ } catch (e) {
13
+ console.error(`cannot read ${path}: ${e.message}`);
14
+ process.exit(2);
15
+ }
16
+ try {
17
+ return JSON.parse(text);
18
+ } catch (e) {
19
+ console.error(`${path} is not valid JSON: ${e.message}`);
20
+ process.exit(2);
21
+ }
22
+ }
23
+
24
+ export function report(issues) {
25
+ for (const i of issues) console.log(`${i.level.toUpperCase().padEnd(5)} ${i.code.padEnd(16)} ${i.where}: ${i.message}`);
26
+ return issues.filter(i => i.level === "error").length;
27
+ }
28
+
29
+ if (process.argv[1]?.endsWith("validate.mjs")) {
30
+ const path = process.argv[2];
31
+ if (!path) {
32
+ console.error("usage: node --experimental-strip-types scripts/validate.mjs <model.json>");
33
+ process.exit(2);
34
+ }
35
+ const errors = report(validateModel(loadModel(path)));
36
+ console.log(errors ? `${errors} error(s)` : "OK");
37
+ process.exit(errors ? 1 : 0);
38
+ }
@@ -0,0 +1,171 @@
1
+ // The page around the canvas: completeness strip, summary tiles, legend, ledger, table view, title,
2
+ // view switch, search box and theme toggle. Text comes from the model; every string goes through esc() or textContent.
3
+
4
+ import { el, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+ import { DASH, toneVar, cssId } from "./stateCss.ts";
7
+ import { bucketOf, linkStateId, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
8
+ import { matches } from "./interact.ts";
9
+
10
+ const DEFAULT_LABELS: Record<string, string> = {
11
+ tileGood: "links in a good state",
12
+ tileNeutral: "links in a neutral state",
13
+ tileGap: "groups with a gap: click to show",
14
+ tileUpgrade: "groups you can improve: click to show",
15
+ ledgerTitle: "What this view could not establish",
16
+ tableTitle: "Table view",
17
+ };
18
+
19
+ export function install(ctx: Ctx): void {
20
+ const { els, model } = ctx;
21
+ const label = (k: string): string => model.meta.labels?.[k] ?? DEFAULT_LABELS[k] ?? k;
22
+ const visible = (id: string): boolean => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, ctx.mode, n); };
23
+
24
+ function countUp(node: HTMLElement, to: number, ms = 700): void {
25
+ if (ctx.reduced) { node.textContent = String(to); return; }
26
+ const t0 = performance.now();
27
+ const step = (t: number) => {
28
+ const k = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - k, 3);
29
+ node.textContent = String(Math.round(to * e));
30
+ if (k < 1) requestAnimationFrame(step);
31
+ };
32
+ requestAnimationFrame(step);
33
+ }
34
+
35
+ function buildStrip(): void {
36
+ const box = els.strip;
37
+ box.style.gridTemplateColumns = `repeat(${Math.max(1, model.columns.length)}, minmax(0, 1fr))`;
38
+ if (!box.firstChild) {
39
+ box.innerHTML = model.columns.map(c => `<div class="lcell"><div class="ln">${esc(c.title)}</div><div class="lb"><i class="good"></i><i class="neutral"></i><i class="gap"></i></div><div class="lv"></div></div>`).join("");
40
+ }
41
+ box.querySelectorAll<HTMLElement>(".lcell").forEach((cell, i) => {
42
+ cell.style.setProperty("--i", String(i));
43
+ let tot = 0, good = 0, neutral = 0, gap = 0;
44
+ for (const n of model.nodes) {
45
+ if (n.col !== i || n.kind === "chip") continue;
46
+ const w = Math.max(1, n.weight ?? 1);
47
+ tot += w;
48
+ if (!visible(n.id)) continue;
49
+ const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
50
+ if (b === "good") good += w; else if (b === "neutral") neutral += w; else gap += w;
51
+ }
52
+ const t = tot || 1, bars = cell.querySelectorAll<HTMLElement>("i");
53
+ requestAnimationFrame(() => {
54
+ if (bars[0]) bars[0].style.width = good / t * 100 + "%";
55
+ if (bars[1]) bars[1].style.width = neutral / t * 100 + "%";
56
+ if (bars[2]) bars[2].style.width = gap / t * 100 + "%";
57
+ });
58
+ const lv = cell.querySelector<HTMLElement>(".lv");
59
+ if (lv) lv.textContent = `${good + neutral} / ${tot}`;
60
+ cell.title = `${model.columns[i]?.title ?? ""}: ${good} good, ${neutral} neutral, ${gap} gap`;
61
+ });
62
+ }
63
+
64
+ function buildTiles(): void {
65
+ const k = els.tiles;
66
+ let good = 0, neutral = 0;
67
+ for (const l of ctx.links) {
68
+ if (!linkVisible(model, ctx.mode, l, ctx.nodes)) continue;
69
+ const b = bucketOf(model, linkStateId(model, ctx.mode, l));
70
+ if (b === "good") good++; else if (b === "neutral") neutral++;
71
+ }
72
+ let gaps = 0, upgrades = 0;
73
+ for (const n of model.nodes) {
74
+ if (!visible(n.id) || n.kind === "chip") continue;
75
+ const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
76
+ if (b === "gap") gaps += 1;
77
+ if (n.upgrade && b !== "good") upgrades++;
78
+ }
79
+ k.innerHTML = "";
80
+ const tile = (cls: string, n: number, text: string, filter?: "gap" | "upgrade") => {
81
+ const b = el(filter ? "button" : "div", "tile " + cls, `<span class="tn">0</span><span class="tl">${esc(text)}</span>`);
82
+ b.style.setProperty("--i", String(k.children.length + 9));
83
+ if (filter) {
84
+ (b as HTMLButtonElement).type = "button";
85
+ b.setAttribute("aria-pressed", String(ctx.filter === filter));
86
+ b.addEventListener("click", () => {
87
+ ctx.filter = ctx.filter === filter ? null : filter;
88
+ ctx.fn.select(null);
89
+ buildTiles();
90
+ ctx.fn.trace(null);
91
+ ctx.fn.announce(ctx.filter ? "Filter on" : "Filter off");
92
+ });
93
+ }
94
+ k.appendChild(b);
95
+ countUp(b.querySelector<HTMLElement>(".tn") as HTMLElement, n);
96
+ };
97
+ tile("ok", good, label("tileGood"));
98
+ tile("", neutral, label("tileNeutral"));
99
+ tile("warn", gaps, label("tileGap"), "gap");
100
+ tile("", upgrades, label("tileUpgrade"), "upgrade");
101
+ }
102
+
103
+ function buildLegend(): void {
104
+ const parts = Object.entries(model.states).map(([id, s]) => {
105
+ const cap = s.edge === "dot" ? ' stroke-linecap="round"' : "";
106
+ return `<span><svg width="40" height="10" aria-hidden="true"><path d="M0 5 H40" stroke="${toneVar(s.tone)}" stroke-width="2" stroke-dasharray="${DASH[s.edge]}"${cap}/></svg><b class="ev st-${cssId(id)}">${esc(s.word)}</b></span>`;
107
+ });
108
+ parts.push('<span id="hintline">scroll = zoom · drag = pan · double-click = fit</span>');
109
+ els.legend.innerHTML = parts.join("");
110
+ }
111
+
112
+ function buildLedger(): void {
113
+ let gaps = 0, total = 0;
114
+ for (const n of model.nodes) { if (n.kind === "chip" || !visible(n.id)) continue; total++; if (bucketOf(model, nodeStateId(model, ctx.mode, n)) === "gap") gaps++; }
115
+ const noLane = model.lanes.filter(l => l.gap).length;
116
+ els.ledger.innerHTML = `<h4>${esc(label("ledgerTitle"))}</h4><p><b>${gaps}</b> of <b>${total}</b> groups in this view have a gap: they are drawn dotted and are never guessed. ${noLane ? `<b>${noLane}</b> lane${noLane === 1 ? " has" : "s have"} no source document yet.` : ""}</p><p>${esc(model.meta.source)}</p>`;
117
+ }
118
+
119
+ function buildTable(): void {
120
+ const rows = model.nodes.filter(n => visible(n.id) && n.kind !== "chip").map(n => {
121
+ const sid = nodeStateId(model, ctx.mode, n);
122
+ const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
123
+ return `<tr><th scope="row">${esc(model.columns[n.col]?.title ?? "")}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
124
+ }).join("");
125
+ els.table.innerHTML = `<thead><tr><th scope="col">Column</th><th scope="col">Lane</th><th scope="col">Group</th><th scope="col">State</th><th scope="col">Members</th><th scope="col">Detail</th><th scope="col">Can improve</th></tr></thead><tbody>${rows}</tbody>`;
126
+ }
127
+
128
+ ctx.fn.buildChrome = () => { buildStrip(); buildTiles(); buildTable(); buildLedger(); };
129
+
130
+ // ---- page text and controls, set up once
131
+ document.title = model.meta.title;
132
+ els.title.textContent = model.meta.title;
133
+ els.help.textContent = model.meta.help;
134
+ els.readat.textContent = model.meta.asOf ? `as of ${model.meta.asOf}` : "";
135
+ els.footer.textContent = model.meta.source;
136
+ buildLegend();
137
+
138
+ const views = model.views ?? [];
139
+ els.modeSwitch.hidden = views.length < 2;
140
+ els.modeSwitch.innerHTML = "";
141
+ for (const v of views) {
142
+ const b = el("button", "seg");
143
+ b.type = "button"; b.setAttribute("role", "tab"); b.dataset.mode = v.id;
144
+ b.setAttribute("aria-selected", String(v.id === ctx.mode));
145
+ b.textContent = v.label;
146
+ on(ctx, b, "click", () => {
147
+ els.modeSwitch.querySelectorAll("button").forEach(x => x.setAttribute("aria-selected", String(x === b)));
148
+ ctx.fn.setMode(v.id);
149
+ });
150
+ els.modeSwitch.appendChild(b);
151
+ }
152
+
153
+ on(ctx, els.q, "input", (e: Event) => {
154
+ ctx.query = (e.target as HTMLInputElement).value.trim().toLowerCase();
155
+ ctx.dismissed = false;
156
+ const n = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).length;
157
+ els.matchinfo.textContent = ctx.query ? `${n} group${n === 1 ? "" : "s"} match` : "";
158
+ if (!ctx.selected) { ctx.fn.trace(null); ctx.fn.renderInspector(); }
159
+ });
160
+ on(ctx, els.q, "keydown", (e: KeyboardEvent) => {
161
+ if (e.key !== "Enter" || !ctx.query) return;
162
+ const ids = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).map(x => x.id);
163
+ const r = ctx.fn.rectOf(ids);
164
+ if (r) { ctx.userMoved = true; ctx.fn.fit(true, r, els.inspector.classList.contains("open") ? 340 : 0); ctx.fn.announce(`Zoomed to ${ids.length} matching groups`); }
165
+ });
166
+ on(ctx, els.theme, "click", () => {
167
+ const r = document.documentElement;
168
+ const dark = r.dataset.theme ? r.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
169
+ r.dataset.theme = dark ? "light" : "dark";
170
+ });
171
+ }
@@ -0,0 +1,231 @@
1
+ // The canvas controls: view (pan and zoom), pinned headers, pointer, wheel, pinch, keys and the tool strip.
2
+ // The maths lives in viewport.ts; this file only reads and writes the DOM.
3
+
4
+ import { on, el, type Ctx } from "./ctx.ts";
5
+ import { clampPan, fitRect, fitWidth, zoomAt, type Rect, type View } from "./viewport.ts";
6
+
7
+ const HEAD = 44; // height of the pinned header overlay
8
+
9
+ const ICON: Record<string, string> = {
10
+ select: '<path d="M5 3l12 6.5-5.2 1.6L9.5 17z"/>',
11
+ hand: '<path d="M8 11V5.5a1.5 1.5 0 013 0V10m0-5a1.5 1.5 0 013 0v5m0-3.5a1.5 1.5 0 013 0V13c0 4-2.5 7-6.5 7S5 17 5 14l-1.6-3.2a1.4 1.4 0 012.4-1.4L8 12"/>',
12
+ plus: '<path d="M12 5v14M5 12h14"/>',
13
+ minus: '<path d="M5 12h14"/>',
14
+ fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
15
+ one: '<path d="M9 8l3-2v12"/><path d="M5 20h14"/>',
16
+ sel: '<circle cx="12" cy="12" r="3.2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/>',
17
+ full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
18
+ };
19
+
20
+ export function install(ctx: Ctx): void {
21
+ const { els, model } = ctx;
22
+ const vp = els.vp;
23
+ // The size is cached: reading clientWidth on every wheel event would force a layout of the whole page each time.
24
+ let cached = { w: vp.clientWidth, h: vp.clientHeight };
25
+ const size = () => cached;
26
+ // Same for the viewport rectangle: it is refreshed on resize, scroll and when the pointer enters.
27
+ let vpRect = vp.getBoundingClientRect();
28
+ const refreshRect = () => { vpRect = vp.getBoundingClientRect(); };
29
+ let animTimer = 0;
30
+ let raf = 0;
31
+
32
+ function applyView(): void {
33
+ const v = ctx.view;
34
+ els.world.style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`;
35
+ vp.style.setProperty("--inv", Math.max(1, 0.9 / v.k).toFixed(3));
36
+ vp.style.setProperty("--k", v.k.toFixed(4));
37
+ vp.classList.toggle("lod1", v.k < 0.62);
38
+ vp.classList.toggle("lod2", v.k < 0.4);
39
+ els.zoom.textContent = Math.round(v.k * 100) + "%";
40
+ ctx.fn.stickLabels();
41
+ ctx.fn.placeHeaders();
42
+ if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (document.body.classList.contains("notes")) ctx.fn.placeMarkers(); });
43
+ }
44
+
45
+ function setView(v: View, animate = false): void {
46
+ const c = clampPan(v, ctx.geom.bounds, size());
47
+ ctx.view = [c.x, c.y, c.k].every(Number.isFinite) ? c : { x: 0, y: 0, k: 1 };
48
+ const glide = animate && !ctx.reduced;
49
+ vp.classList.toggle("anim", glide);
50
+ if (glide) { clearTimeout(animTimer); animTimer = window.setTimeout(() => vp.classList.remove("anim"), 330); }
51
+ applyView();
52
+ }
53
+
54
+ function fit(animate = false, rect?: Rect, inset = 0): void {
55
+ const r = rect ?? ctx.geom.bounds;
56
+ if (!(r.w > 0)) return;
57
+ const s = size();
58
+ setView(fitRect(r, { w: s.w - inset, h: s.h }, 28, HEAD + 8), animate);
59
+ }
60
+ function fitW(animate = false): void {
61
+ setView(fitWidth(ctx.geom.bounds, size(), 28, HEAD + 8), animate);
62
+ }
63
+ function zoomBy(f: number): void {
64
+ const s = size();
65
+ ctx.userMoved = true;
66
+ setView(zoomAt(ctx.view, f, s.w / 2, s.h / 2), true);
67
+ ctx.fn.announce("Zoom " + Math.round(ctx.view.k * 100) + " percent");
68
+ }
69
+
70
+ function placeHeaders(): void {
71
+ const cols = els.cols;
72
+ if (!cols.firstChild) {
73
+ model.columns.forEach((c, i) => { const h = el("div", "hd"); h.textContent = (c.short ?? c.title).toUpperCase(); h.title = c.title; h.dataset.i = String(i); cols.appendChild(h); });
74
+ let i = 0;
75
+ while (i < model.columns.length) {
76
+ const name = model.columns[i]?.phase;
77
+ let j = i;
78
+ while (j + 1 < model.columns.length && model.columns[j + 1]?.phase === name) j++;
79
+ if (name) { const p = el("div", "ph"); p.innerHTML = "<span></span><i></i>"; (p.firstElementChild as HTMLElement).textContent = name; p.dataset.a = String(i); p.dataset.b = String(j); cols.appendChild(p); }
80
+ i = j + 1;
81
+ }
82
+ }
83
+ const v = ctx.view, xs = ctx.geom.xs;
84
+ cols.querySelectorAll<HTMLElement>(".hd").forEach(h => { const i = +(h.dataset.i ?? 0); h.style.left = v.x + (xs[i] ?? 0) * v.k + "px"; h.style.width = Math.max(0, (model.columns[i]?.width ?? 0) * v.k - 6) + "px"; });
85
+ cols.querySelectorAll<HTMLElement>(".ph").forEach(p => {
86
+ const a = +(p.dataset.a ?? 0), b = +(p.dataset.b ?? 0);
87
+ p.style.left = v.x + (xs[a] ?? 0) * v.k + "px";
88
+ p.style.width = Math.max(40, ((xs[b] ?? 0) + (model.columns[b]?.width ?? 0) - (xs[a] ?? 0)) * v.k) + "px";
89
+ });
90
+ }
91
+
92
+ function stickLabels(): void {
93
+ const wl = (52 - ctx.view.x) / ctx.view.k;
94
+ for (const l of model.lanes) {
95
+ const r = ctx.geom.lanes[l.id], d = ctx.laneEls.get(l.id);
96
+ if (!r || !d?.firstElementChild) continue;
97
+ (d.firstElementChild as HTMLElement).style.left = Math.min(Math.max(12, wl - r.x), Math.max(12, r.w - 300)) + "px";
98
+ }
99
+ }
100
+
101
+ // ---- pointer: drag the background (or any spot with the hand tool, Space, or the middle button) to pan; pinch to zoom
102
+ const pointers = new Map<number, { x: number; y: number }>();
103
+ let drag: { x: number; y: number; vx: number; vy: number } | null = null;
104
+ let pinch: { d: number; view: View } | null = null;
105
+
106
+ on(ctx, vp, "pointerdown", (e: PointerEvent) => {
107
+ if ((e.target as HTMLElement).closest("#tools, .bandlabel")) return;
108
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
109
+ ctx.moved = 0;
110
+ if (pointers.size === 2) {
111
+ const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
112
+ pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), view: { ...ctx.view } };
113
+ drag = null;
114
+ return;
115
+ }
116
+ const onNode = !!(e.target as HTMLElement).closest(".node");
117
+ if (e.button === 1 || ctx.tool === "hand" || ctx.spaceDown || !onNode) {
118
+ drag = { x: e.clientX, y: e.clientY, vx: ctx.view.x, vy: ctx.view.y };
119
+ try { vp.setPointerCapture(e.pointerId); } catch { /* a synthetic pointer cannot be captured */ }
120
+ vp.classList.add("panning");
121
+ if (e.button === 1) e.preventDefault();
122
+ }
123
+ });
124
+ on(ctx, vp, "pointermove", (e: PointerEvent) => {
125
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
126
+ if (pinch && pointers.size === 2) {
127
+ const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
128
+ const d = Math.hypot(a.x - b.x, a.y - b.y), r = vpRect;
129
+ ctx.userMoved = true;
130
+ setView(zoomAt(pinch.view, d / pinch.d, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top), false);
131
+ return;
132
+ }
133
+ if (!drag) return;
134
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
135
+ ctx.moved = Math.max(ctx.moved, Math.hypot(dx, dy));
136
+ if (ctx.moved > 4) { ctx.userMoved = true; setView({ k: ctx.view.k, x: drag.vx + dx, y: drag.vy + dy }, false); }
137
+ });
138
+ const endDrag = (e: PointerEvent) => {
139
+ pointers.delete(e.pointerId);
140
+ if (pointers.size < 2) pinch = null;
141
+ if (drag) {
142
+ drag = null;
143
+ vp.classList.remove("panning");
144
+ try { vp.releasePointerCapture(e.pointerId); } catch { /* not captured */ }
145
+ window.setTimeout(() => { ctx.moved = 0; }, 0);
146
+ }
147
+ };
148
+ on(ctx, vp, "pointerup", endDrag);
149
+ on(ctx, vp, "pointercancel", endDrag);
150
+ on(ctx, vp, "wheel", (e: WheelEvent) => {
151
+ e.preventDefault();
152
+ const r = vpRect, unit = e.deltaMode === 1 ? 16 : 1;
153
+ ctx.userMoved = true;
154
+ setView(zoomAt(ctx.view, Math.exp(-e.deltaY * unit * (e.ctrlKey ? 0.01 : 0.0012)), e.clientX - r.left, e.clientY - r.top), false);
155
+ }, { passive: false });
156
+ on(ctx, vp, "dblclick", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools")) { ctx.userMoved = false; fit(true); } });
157
+ on(ctx, vp, "click", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools") && ctx.moved <= 4) ctx.fn.select(null); });
158
+
159
+ const ro = new ResizeObserver(() => { cached = { w: vp.clientWidth, h: vp.clientHeight }; refreshRect(); if (!ctx.userMoved) fitW(false); else setView(ctx.view, false); });
160
+ ro.observe(vp);
161
+ on(ctx, vp, "pointerenter", refreshRect);
162
+ on(ctx, window, "scroll", refreshRect, { passive: true });
163
+ ctx.disposers.push(() => ro.disconnect());
164
+
165
+ // ---- tools
166
+ function setTool(id: "select" | "hand"): void {
167
+ ctx.tool = id;
168
+ vp.classList.toggle("hand", id === "hand");
169
+ els.tools.querySelectorAll<HTMLElement>("[data-id='select'], [data-id='hand']").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.id === id)));
170
+ ctx.fn.announce(id === "hand" ? "Hand tool: drag to pan" : "Select tool");
171
+ }
172
+ function toggleFull(): void {
173
+ const ws = els.workspace;
174
+ if (document.fullscreenElement) void document.exitFullscreen();
175
+ else if (ws.requestFullscreen) ws.requestFullscreen().catch(() => ws.classList.toggle("fs"));
176
+ else ws.classList.toggle("fs");
177
+ }
178
+ type Tool = [string, string, "tool" | "full" | (() => void)] | ["-"];
179
+ const TOOLS: Tool[] = [
180
+ ["select", "Select (V)", "tool"], ["hand", "Hand: drag to pan (H)", "tool"], ["-"],
181
+ ["plus", "Zoom in (+)", () => zoomBy(1.25)], ["minus", "Zoom out (-)", () => zoomBy(1 / 1.25)],
182
+ ["fit", "Fit to screen (0)", () => { ctx.userMoved = false; fit(true); ctx.fn.announce("Fit to screen"); }],
183
+ ["one", "Actual size (1)", () => { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }],
184
+ ["sel", "Zoom to selection (F)", () => ctx.fn.zoomToPath()], ["-"],
185
+ ["full", "Fullscreen", "full"],
186
+ ];
187
+ els.tools.innerHTML = "";
188
+ for (const t of TOOLS) {
189
+ if (t[0] === "-") { els.tools.appendChild(el("div", "sep")); continue; }
190
+ const [id, label, act] = t as [string, string, "tool" | "full" | (() => void)];
191
+ const b = el("button", "tb", `<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[id] ?? ""}</svg>`);
192
+ b.type = "button"; b.title = label; b.setAttribute("aria-label", label); b.dataset.id = id;
193
+ if (act === "tool") { b.setAttribute("aria-pressed", String(ctx.tool === id)); on(ctx, b, "click", () => setTool(id as "select" | "hand")); }
194
+ else if (act === "full") on(ctx, b, "click", toggleFull);
195
+ else on(ctx, b, "click", act);
196
+ els.tools.appendChild(b);
197
+ }
198
+ requestAnimationFrame(() => { const r = els.tools.getBoundingClientRect(), v = vp.getBoundingClientRect(); els.zoom.style.top = r.bottom - v.top + 6 + "px"; });
199
+ on(ctx, document, "fullscreenchange", () => window.setTimeout(() => { if (!ctx.userMoved) fitW(false); }, 60));
200
+
201
+ // ---- keys
202
+ on(ctx, document, "keydown", (e: KeyboardEvent) => {
203
+ const typing = !!e.target && /INPUT|TEXTAREA/.test((e.target as HTMLElement).tagName);
204
+ if (typing) { if (e.key === "Escape") (e.target as HTMLElement).blur(); return; }
205
+ if (e.ctrlKey || e.metaKey || e.altKey) return; // never hijack Ctrl+F, Ctrl+N, Ctrl+plus and the like
206
+ if (e.key === " " && document.activeElement === vp) { ctx.spaceDown = true; e.preventDefault(); vp.classList.add("hand"); return; }
207
+ const k = e.key, step = e.shiftKey ? 240 : 60;
208
+ if (k === "/") { e.preventDefault(); els.q.focus(); }
209
+ else if (k === "n" || k === "N") ctx.fn.toggleNotes();
210
+ else if (k === "v" || k === "V") setTool("select");
211
+ else if (k === "h" || k === "H") setTool("hand");
212
+ else if (k === "+" || k === "=") zoomBy(1.25);
213
+ else if (k === "-" || k === "_") zoomBy(1 / 1.25);
214
+ else if (k === "0") { ctx.userMoved = false; fit(true); }
215
+ else if (k === "1") { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }
216
+ else if (k === "f" || k === "F") ctx.fn.zoomToPath();
217
+ else if (k.startsWith("Arrow") && (document.activeElement === vp || document.activeElement === document.body)) {
218
+ e.preventDefault();
219
+ ctx.userMoved = true;
220
+ setView({ k: ctx.view.k, x: ctx.view.x + (k === "ArrowLeft" ? step : k === "ArrowRight" ? -step : 0), y: ctx.view.y + (k === "ArrowUp" ? step : k === "ArrowDown" ? -step : 0) }, false);
221
+ } else if (k === "Escape") ctx.fn.escape();
222
+ });
223
+ on(ctx, document, "keyup", (e: KeyboardEvent) => { if (e.key === " ") { ctx.spaceDown = false; if (ctx.tool !== "hand") vp.classList.remove("hand"); } });
224
+
225
+ ctx.fn.setView = setView;
226
+ ctx.fn.fit = fit;
227
+ ctx.fn.fitWidth = fitW;
228
+ ctx.fn.zoomBy = zoomBy;
229
+ ctx.fn.stickLabels = stickLabels;
230
+ ctx.fn.placeHeaders = placeHeaders;
231
+ }
@@ -0,0 +1,118 @@
1
+ // The engine's shared state. Each DOM module receives one Ctx, reads and writes it, and registers
2
+ // its functions on ctx.fn, so modules call each other without importing each other.
3
+
4
+ import type { Model, NodeDef, LinkDef, NoteDef } from "./model.ts";
5
+ import type { View, Rect } from "./viewport.ts";
6
+ import type { Geometry } from "./layout.ts";
7
+
8
+ export interface Els {
9
+ vp: HTMLElement;
10
+ world: HTMLElement;
11
+ svg: SVGSVGElement;
12
+ layer: SVGGElement;
13
+ cols: HTMLElement;
14
+ tools: HTMLElement;
15
+ zoom: HTMLElement;
16
+ inspector: HTMLElement;
17
+ workspace: HTMLElement;
18
+ live: HTMLElement;
19
+ strip: HTMLElement;
20
+ tiles: HTMLElement;
21
+ legend: HTMLElement;
22
+ ledger: HTMLElement;
23
+ table: HTMLElement;
24
+ q: HTMLInputElement;
25
+ matchinfo: HTMLElement;
26
+ modeSwitch: HTMLElement;
27
+ drawer: HTMLElement;
28
+ notelist: HTMLElement;
29
+ notesBtn: HTMLElement;
30
+ theme: HTMLElement;
31
+ specbody: HTMLElement;
32
+ spec: HTMLElement;
33
+ title: HTMLElement;
34
+ readat: HTMLElement;
35
+ help: HTMLElement;
36
+ footer: HTMLElement;
37
+ }
38
+
39
+ export type Filter = "gap" | "upgrade" | null;
40
+
41
+ export interface Fns {
42
+ paint(fromSwitch?: boolean): void;
43
+ buildWorld(): void;
44
+ trace(id: string | null): void;
45
+ select(id: string | null): void;
46
+ renderInspector(): void;
47
+ setView(v: View, animate?: boolean): void;
48
+ fit(animate?: boolean, rect?: Rect, inset?: number): void;
49
+ fitWidth(animate?: boolean): void;
50
+ zoomBy(factor: number): void;
51
+ zoomToPath(): void;
52
+ zoomToBand(id: string): void;
53
+ rectOf(ids: string[]): Rect | null;
54
+ drawTrail(): void;
55
+ simulate(actionId: string): void;
56
+ clearSim(): void;
57
+ announce(text: string): void;
58
+ stickLabels(): void;
59
+ placeHeaders(): void;
60
+ buildChrome(): void;
61
+ toggleNotes(on?: boolean): void;
62
+ placeMarkers(): void;
63
+ escape(): void;
64
+ setMode(id: string): void;
65
+ }
66
+
67
+ export interface Ctx {
68
+ model: Model;
69
+ root: ParentNode;
70
+ els: Els;
71
+ notes: NoteDef[];
72
+ nodes: Map<string, NodeDef>;
73
+ links: LinkDef[];
74
+ geom: Geometry;
75
+ view: View;
76
+ reduced: boolean;
77
+ mode: string;
78
+ selected: string | null;
79
+ hl: string | null;
80
+ query: string;
81
+ filter: Filter;
82
+ tool: "select" | "hand";
83
+ spaceDown: boolean;
84
+ userMoved: boolean;
85
+ dismissed: boolean;
86
+ simActive: boolean;
87
+ moved: number;
88
+ nodeEls: Map<string, HTMLElement>;
89
+ laneEls: Map<string, HTMLElement>;
90
+ edgeEls: SVGPathElement[];
91
+ labelEls: HTMLElement[];
92
+ overlays: SVGPathElement[];
93
+ simTimers: number[];
94
+ disposers: Array<() => void>;
95
+ fn: Fns;
96
+ }
97
+
98
+ export const SVG_NS = "http://www.w3.org/2000/svg";
99
+
100
+ /** Make an element with a class and (trusted, already escaped) inner HTML. */
101
+ export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] {
102
+ const e = document.createElement(tag);
103
+ if (cls) e.className = cls;
104
+ if (html !== undefined) e.innerHTML = html;
105
+ return e;
106
+ }
107
+
108
+ export function sv(tag: string, attrs: Record<string, string>): SVGElement {
109
+ const e = document.createElementNS(SVG_NS, tag);
110
+ for (const k of Object.keys(attrs)) e.setAttribute(k, attrs[k] as string);
111
+ return e;
112
+ }
113
+
114
+ /** Listen on a target and remember how to stop, so destroy() removes every listener. */
115
+ export function on<T extends EventTarget>(ctx: Ctx, target: T, type: string, fn: (e: any) => void, opts?: AddEventListenerOptions): void {
116
+ target.addEventListener(type, fn, opts);
117
+ ctx.disposers.push(() => target.removeEventListener(type, fn, opts));
118
+ }