@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,86 @@
|
|
|
1
|
+
// View rules: which nodes and links show in which view, and in which state. Pure, no DOM.
|
|
2
|
+
// A model may define views (for example "Today" and "Complete"). The first view is the baseline.
|
|
3
|
+
// A model with no views has one implicit view named "all".
|
|
4
|
+
|
|
5
|
+
import type { Model, NodeDef, LinkDef, Bucket } from "./model.ts";
|
|
6
|
+
|
|
7
|
+
export const baselineView = (m: Model): string => m.views?.[0]?.id ?? "all";
|
|
8
|
+
export const defaultView = (m: Model): string => (m.views?.find(v => v.default) ?? m.views?.[0])?.id ?? "all";
|
|
9
|
+
|
|
10
|
+
/** A group that contains nodes starts folded unless it explicitly opts out. */
|
|
11
|
+
export const initialCollapsed = (m: Model): Set<string> => new Set(m.nodes
|
|
12
|
+
.filter(n => n.kind === "group" && n.contains && n.collapsed !== false).map(n => n.id));
|
|
13
|
+
export const columnTitle = (m: Model, n: NodeDef): string => n.col === undefined ? "" : m.columns[n.col]?.title ?? "";
|
|
14
|
+
|
|
15
|
+
export function groupFor(m: Model, nodeId: string): NodeDef | undefined {
|
|
16
|
+
return m.nodes.find(n => n.kind === "group" && n.contains?.includes(nodeId));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const nodeVisible = (m: Model, view: string, n: NodeDef, collapsed?: Set<string>): boolean => {
|
|
20
|
+
if (n.visibleIn && !n.visibleIn.includes(view)) return false;
|
|
21
|
+
if (!collapsed) return true;
|
|
22
|
+
if (n.kind === "group" && n.contains) return collapsed.has(n.id);
|
|
23
|
+
const owner = groupFor(m, n.id);
|
|
24
|
+
return !owner || !collapsed.has(owner.id);
|
|
25
|
+
};
|
|
26
|
+
export function toggleGroupState(m: Model, view: string, collapsed: Set<string>, id: string):
|
|
27
|
+
{ collapsed: Set<string>; selected: string | null; expanding: boolean } | null {
|
|
28
|
+
const group = m.nodes.find(n => n.id === id && n.kind === "group" && n.contains?.length);
|
|
29
|
+
if (!group) return null;
|
|
30
|
+
const next = new Set(collapsed);
|
|
31
|
+
const expanding = next.has(id);
|
|
32
|
+
if (expanding) next.delete(id); else next.add(id);
|
|
33
|
+
const selected = expanding ? group.contains?.find(child => {
|
|
34
|
+
const n = m.nodes.find(candidate => candidate.id === child);
|
|
35
|
+
return !!n && nodeVisible(m, view, n, next);
|
|
36
|
+
}) ?? null : id;
|
|
37
|
+
return { collapsed: next, selected, expanding };
|
|
38
|
+
}
|
|
39
|
+
export const nodeStateId = (_m: Model, view: string, n: NodeDef): string => n.stateIn?.[view] ?? n.state;
|
|
40
|
+
|
|
41
|
+
export function linkVisible(m: Model, view: string, l: LinkDef, nodes: Map<string, NodeDef>): boolean {
|
|
42
|
+
if (l.visibleIn && !l.visibleIn.includes(view)) return false;
|
|
43
|
+
const a = nodes.get(l.from);
|
|
44
|
+
const b = nodes.get(l.to);
|
|
45
|
+
return !!a && !!b && nodeVisible(m, view, a) && nodeVisible(m, view, b);
|
|
46
|
+
}
|
|
47
|
+
export const linkStateId = (_m: Model, view: string, l: LinkDef): string => l.stateIn?.[view] ?? l.state;
|
|
48
|
+
|
|
49
|
+
/** An unknown state counts as a gap: a typo must never read as "good". */
|
|
50
|
+
export const bucketOf = (m: Model, stateId: string): Bucket => m.states[stateId]?.bucket ?? "gap";
|
|
51
|
+
|
|
52
|
+
/** Visible now, but not in the baseline view: the view adds this node. */
|
|
53
|
+
export const isNew = (m: Model, view: string, n: NodeDef): boolean =>
|
|
54
|
+
view !== baselineView(m) && nodeVisible(m, view, n) && !nodeVisible(m, baselineView(m), n);
|
|
55
|
+
|
|
56
|
+
/** A gap in the baseline view that this view has filled. */
|
|
57
|
+
export const isImproved = (m: Model, view: string, n: NodeDef): boolean => {
|
|
58
|
+
const base = baselineView(m);
|
|
59
|
+
return view !== base && nodeVisible(m, base, n) && bucketOf(m, nodeStateId(m, base, n)) === "gap" && bucketOf(m, nodeStateId(m, view, n)) !== "gap";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A portal chip is an alias of the node it points at. */
|
|
63
|
+
export const canonOf = (nodes: Map<string, NodeDef>, id: string): string => nodes.get(id)?.ref ?? id;
|
|
64
|
+
|
|
65
|
+
/** Map hidden members to their visible group and merge parallel effective links. */
|
|
66
|
+
export function effectiveLinks(m: Model, view: string, collapsed: Set<string>, nodes: Map<string, NodeDef>): LinkDef[] {
|
|
67
|
+
const byPair = new Map<string, LinkDef>();
|
|
68
|
+
const rank = { good: 0, neutral: 1, gap: 2 };
|
|
69
|
+
for (const link of m.links) {
|
|
70
|
+
if (link.visibleIn && !link.visibleIn.includes(view)) continue;
|
|
71
|
+
const a = nodes.get(link.from), b = nodes.get(link.to);
|
|
72
|
+
if (!a || !b || (a.visibleIn && !a.visibleIn.includes(view)) || (b.visibleIn && !b.visibleIn.includes(view))) continue;
|
|
73
|
+
const groupA = groupFor(m, a.id), groupB = groupFor(m, b.id);
|
|
74
|
+
const from = groupA && collapsed.has(groupA.id) ? groupA.id : a.id;
|
|
75
|
+
const to = groupB && collapsed.has(groupB.id) ? groupB.id : b.id;
|
|
76
|
+
if (from === to) continue;
|
|
77
|
+
const fromNode = nodes.get(from), toNode = nodes.get(to);
|
|
78
|
+
if (!fromNode || !toNode || !nodeVisible(m, view, fromNode, collapsed) || !nodeVisible(m, view, toNode, collapsed)) continue;
|
|
79
|
+
const state = linkStateId(m, view, link);
|
|
80
|
+
const key = JSON.stringify([from, to]);
|
|
81
|
+
const prev = byPair.get(key);
|
|
82
|
+
if (!prev) byPair.set(key, { ...link, from, to, state, stateIn: undefined });
|
|
83
|
+
else if (rank[bucketOf(m, state)] > rank[bucketOf(m, prev.state)]) prev.state = state;
|
|
84
|
+
}
|
|
85
|
+
return [...byPair.values()];
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { notifySelection } from "./selection.ts";
|
|
4
|
+
|
|
5
|
+
test("selection callback reports changes and clears once", () => {
|
|
6
|
+
const seen: Array<string | null> = [];
|
|
7
|
+
const notify = (id: string | null) => seen.push(id);
|
|
8
|
+
notifySelection(null, "a", notify);
|
|
9
|
+
notifySelection("a", "a", notify);
|
|
10
|
+
notifySelection("a", null, notify);
|
|
11
|
+
assert.deepEqual(seen, ["a", null]);
|
|
12
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { Model } from "./model.ts";
|
|
4
|
+
import { stateCss, toneVar } from "./stateCss.ts";
|
|
5
|
+
|
|
6
|
+
const states: Model["states"] = {
|
|
7
|
+
live: { word: "LIVE", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" },
|
|
8
|
+
doc: { word: "DOC", tone: "muted", border: "dashed", edge: "dash", motion: "flow-slow", bucket: "neutral" },
|
|
9
|
+
code: { word: "CODE", tone: "muted", border: "dashed", edge: "dash-short", motion: "flow-slow", bucket: "neutral" },
|
|
10
|
+
gap: { word: "GAP", tone: "warn", border: "dotted", edge: "dot", motion: "breathe", bucket: "gap" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test("toneVar maps every tone to a CSS variable", () => {
|
|
14
|
+
for (const t of ["ok", "warn", "crit", "info", "muted", "accent"] as const) assert.match(toneVar(t), /^var\(--[a-z]+\)$/);
|
|
15
|
+
});
|
|
16
|
+
test("each state gets a node rule, a word rule and an edge rule", () => {
|
|
17
|
+
const css = stateCss(states);
|
|
18
|
+
for (const id of Object.keys(states)) {
|
|
19
|
+
assert.ok(css.includes(`.node.st-${id}`), `node ${id}`);
|
|
20
|
+
assert.ok(css.includes(`.ev.st-${id}`), `word ${id}`);
|
|
21
|
+
assert.ok(css.includes(`path.edge.st-${id}`), `edge ${id}`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
test("gateway polygons receive each state's stroke and tint", () => {
|
|
25
|
+
const css = stateCss(states);
|
|
26
|
+
for (const id of Object.keys(states)) {
|
|
27
|
+
assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*stroke:var\\(--`));
|
|
28
|
+
assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*fill:color-mix\\(`));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
test("a dotted state is a tinted card with a dotted border", () => {
|
|
32
|
+
const css = stateCss(states);
|
|
33
|
+
assert.match(css, /\.node\.st-gap\s*\{[^}]*dotted/);
|
|
34
|
+
assert.match(css, /\.node\.st-gap\s*\{[^}]*background/);
|
|
35
|
+
});
|
|
36
|
+
test("a solid state has a solid left border", () => assert.match(stateCss(states), /\.node\.st-live\s*\{[^}]*border-left:4px solid/));
|
|
37
|
+
test("breathe adds the breathing animation only to the node", () => {
|
|
38
|
+
const css = stateCss(states);
|
|
39
|
+
assert.match(css, /\.node\.st-gap:not\(\.dim\)\s*\{[^}]*animation:breathe/);
|
|
40
|
+
assert.doesNotMatch(css, /path\.edge\.st-gap\s*\{[^}]*animation/);
|
|
41
|
+
});
|
|
42
|
+
test("flow speed follows the motion: fast for flow, slow for flow-slow", () => {
|
|
43
|
+
const css = stateCss(states);
|
|
44
|
+
assert.match(css, /path\.edge\.st-live\s*\{[^}]*flow 3s/);
|
|
45
|
+
assert.match(css, /path\.edge\.st-doc\s*\{[^}]*flowd 7s/);
|
|
46
|
+
assert.match(css, /path\.edge\.st-code\s*\{[^}]*flowc 7s/);
|
|
47
|
+
});
|
|
48
|
+
test("the dash pattern follows the edge style", () => {
|
|
49
|
+
const css = stateCss(states);
|
|
50
|
+
assert.match(css, /path\.edge\.st-live\s*\{[^}]*stroke-dasharray:5 9/);
|
|
51
|
+
assert.match(css, /path\.edge\.st-doc\s*\{[^}]*stroke-dasharray:7 6/);
|
|
52
|
+
assert.match(css, /path\.edge\.st-code\s*\{[^}]*stroke-dasharray:3 6/);
|
|
53
|
+
assert.match(css, /path\.edge\.st-gap\s*\{[^}]*stroke-dasharray:2 6/);
|
|
54
|
+
});
|
|
55
|
+
test("a flowing state turns solid under reduced motion", () => {
|
|
56
|
+
const css = stateCss(states);
|
|
57
|
+
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[^@]*path\.edge\.st-live[^}]*stroke-dasharray:none/);
|
|
58
|
+
});
|
|
59
|
+
test("an unexpected edge or motion value never prints 'undefined' into the CSS", () => {
|
|
60
|
+
const css = stateCss({ odd: { word: "ODD", tone: "ok", border: "solid", edge: "wavy" as any, motion: "spin" as any, bucket: "good" } });
|
|
61
|
+
assert.doesNotMatch(css, /undefined/);
|
|
62
|
+
});
|
|
63
|
+
test("names from Object.prototype are not accepted as an edge style", () => {
|
|
64
|
+
for (const edge of ["constructor", "toString", "__proto__", "hasOwnProperty"]) {
|
|
65
|
+
const css = stateCss({ odd: { word: "ODD", tone: edge as any, border: "solid", edge: edge as any, motion: "flow", bucket: "good" } });
|
|
66
|
+
assert.doesNotMatch(css, /native code|function|undefined|\[object/, edge);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
test("a state id with markup characters cannot break out of the selector", () => {
|
|
70
|
+
const css = stateCss({ 'x"}<script>': states.live });
|
|
71
|
+
assert.doesNotMatch(css, /<script>/);
|
|
72
|
+
assert.doesNotMatch(css, /x"\}/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// CSS generated from the model's states. Each state becomes a class (st-<id>) for its card, its word
|
|
2
|
+
// and its links, so the engine never assumes state names. The result goes BEFORE the static styles, so
|
|
3
|
+
// the static rules for hover, select and failure win when specificity is equal.
|
|
4
|
+
|
|
5
|
+
import type { Model, Tone, EdgeStyle } from "./model.ts";
|
|
6
|
+
|
|
7
|
+
/** A state id used as a CSS class. The model validator also restricts ids to this alphabet. */
|
|
8
|
+
export const cssId = (id: string): string => id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
9
|
+
|
|
10
|
+
const TONES: Record<Tone, string> = { ok: "var(--ok)", warn: "var(--warn)", crit: "var(--crit)", info: "var(--up)", muted: "var(--muted)", accent: "var(--accent)" };
|
|
11
|
+
export const toneVar = (t: Tone): string => (Object.hasOwn(TONES, t) ? TONES[t] : TONES.muted);
|
|
12
|
+
|
|
13
|
+
export const DASH: Record<EdgeStyle, string> = { flow: "5 9", dash: "7 6", "dash-short": "3 6", dot: "2 6" };
|
|
14
|
+
const KEYFRAMES: Record<EdgeStyle, string> = { flow: "flow", dash: "flowd", "dash-short": "flowc", dot: "" };
|
|
15
|
+
|
|
16
|
+
export function stateCss(states: Model["states"]): string {
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
const reduced: string[] = [];
|
|
19
|
+
for (const [id, s] of Object.entries(states)) {
|
|
20
|
+
const c = cssId(id);
|
|
21
|
+
const T = toneVar(s.tone);
|
|
22
|
+
if (s.border === "dotted") {
|
|
23
|
+
out.push(`.node.st-${c} { border:1px dotted ${T}; background:color-mix(in srgb, ${T} 10%, var(--panel)); --breath:${T}; }`);
|
|
24
|
+
out.push(`.node.st-${c} .t { color:${T}; }`);
|
|
25
|
+
} else {
|
|
26
|
+
const glow = s.bucket === "good" ? ` box-shadow:0 0 14px -4px color-mix(in srgb, ${T} 55%, transparent), var(--shadow);` : "";
|
|
27
|
+
out.push(`.node.st-${c} { border-left:4px ${s.border} ${T};${glow} --breath:${T}; }`);
|
|
28
|
+
}
|
|
29
|
+
out.push(`.node.st-${c}.gateway polygon { stroke:${T}; fill:color-mix(in srgb, ${T} 10%, var(--panel)); }`);
|
|
30
|
+
out.push(`.ev.st-${c} { color:${T}; }`);
|
|
31
|
+
if (s.motion === "breathe") out.push(`.node.st-${c}:not(.dim) { animation:breathe 3.2s ease-in-out infinite; }`);
|
|
32
|
+
|
|
33
|
+
const edge: EdgeStyle = Object.hasOwn(DASH, s.edge) ? s.edge : "dash"; // a closed table: an unexpected value never reaches the CSS text
|
|
34
|
+
const flows = edge !== "dot" && (s.motion === "flow" || s.motion === "flow-slow");
|
|
35
|
+
let rule = `stroke:${T}; stroke-dasharray:${DASH[edge]}; opacity:${s.motion === "flow" ? ".85" : ".9"};`;
|
|
36
|
+
if (edge === "dot") rule += " stroke-linecap:round;";
|
|
37
|
+
if (flows) rule += ` animation:${KEYFRAMES[edge]} ${s.motion === "flow" ? "3s" : "7s"} linear infinite;`;
|
|
38
|
+
if (flows && s.motion === "flow") rule += ` filter:drop-shadow(0 0 3px ${T});`;
|
|
39
|
+
out.push(`path.edge.st-${c} { ${rule} }`);
|
|
40
|
+
if (s.bucket === "gap") out.push(`path.edge.hot.st-${c} { stroke:${T}; filter:drop-shadow(0 0 5px ${T}); }`);
|
|
41
|
+
if (flows) reduced.push(`path.edge.st-${c} { stroke-dasharray:none; }`);
|
|
42
|
+
}
|
|
43
|
+
if (reduced.length) out.push(`@media (prefers-reduced-motion: reduce) { ${reduced.join(" ")} }`);
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
|
|
3
|
+
--accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
|
|
4
|
+
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035);
|
|
5
|
+
--warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
|
|
6
|
+
--mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
|
7
|
+
}
|
|
8
|
+
@media (prefers-color-scheme: dark) {
|
|
9
|
+
:root:not([data-theme="light"]) {
|
|
10
|
+
--bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
|
|
11
|
+
--accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
|
|
12
|
+
--up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
|
|
13
|
+
--warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
:root[data-theme="dark"] {
|
|
17
|
+
--bg:#0d1117; --panel:#161b22; --panel2:#0d1117; --edge:#30363d; --rule:#2b323a; --fg:#e6edf3; --muted:#9198a1;
|
|
18
|
+
--accent:#4493f8; --accent-solid:#1f6feb; --ok:#3fb950; --warn:#d29922; --crit:#f85149; --glow:rgba(68,147,248,.35);
|
|
19
|
+
--up:#6fcbe6; --down:#d0a0ee; --shadow:0 1px 0 rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.45); --grid:rgba(255,255,255,.04);
|
|
20
|
+
--warn-bg:rgba(210,153,34,.14); --ok-bg:rgba(63,185,80,.12); --crit-bg:rgba(248,81,73,.12); --info-bg:rgba(68,147,248,.14);
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing:border-box; }
|
|
23
|
+
body { margin:0; color:var(--fg); font:14px/1.45 Inter, "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
24
|
+
background:radial-gradient(900px 380px at 8% -10%, var(--glow), transparent 60%), var(--bg); }
|
|
25
|
+
.mono { font-family:var(--mono); }
|
|
26
|
+
header.top { display:flex; align-items:center; gap:16px; padding:10px 20px; border-bottom:1px solid var(--edge); background:color-mix(in srgb, var(--panel) 88%, transparent); backdrop-filter:blur(10px); position:sticky; top:0; z-index:70; }
|
|
27
|
+
.brand { font-weight:700; display:flex; align-items:center; gap:8px; }
|
|
28
|
+
.brand i { width:10px; height:10px; border-radius:50%; background:var(--ok); animation:beat 2.6s infinite; }
|
|
29
|
+
.crumb { color:var(--muted); } .spacer { flex:1; }
|
|
30
|
+
button, select, input { font:inherit; color:var(--fg); background:var(--panel); border:1px solid var(--edge); border-radius:6px; padding:4px 11px; }
|
|
31
|
+
button { cursor:pointer; transition:border-color .15s, box-shadow .15s, background .15s; }
|
|
32
|
+
button:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--glow); }
|
|
33
|
+
button:focus-visible, input:focus-visible, .node:focus-visible, summary:focus-visible, #viewport:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
34
|
+
button[aria-pressed="true"].tb, #notesBtn[aria-pressed="true"], button.seg[aria-selected="true"] { background:var(--accent-solid); border-color:var(--accent-solid); color:#fff; }
|
|
35
|
+
main { width:100%; padding:12px 14px 20px; }
|
|
36
|
+
.titlerow { display:flex; align-items:baseline; gap:16px; flex-wrap:wrap; }
|
|
37
|
+
h1 { margin:0 0 2px; font-size:20px; padding-bottom:5px; border-bottom:3px solid var(--accent); display:inline-block; }
|
|
38
|
+
.readline { color:var(--muted); font-size:12px; }
|
|
39
|
+
p.help { margin:0 0 8px; color:var(--muted); max-width:130ch; }
|
|
40
|
+
.summary { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(0,1fr); gap:10px; margin:0 0 8px; }
|
|
41
|
+
.layerstrip { display:grid; grid-template-columns:repeat(9,minmax(0,1fr)); gap:1px; background:var(--rule); border:1px solid var(--rule); }
|
|
42
|
+
.lcell { background:var(--panel); padding:7px 9px; min-width:0; }
|
|
43
|
+
.lcell .ln { font-size:10.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
44
|
+
.lcell .lb { display:flex; height:5px; margin:5px 0 4px; background:var(--panel2); border:1px solid var(--rule); overflow:hidden; }
|
|
45
|
+
.lcell .lb i { display:block; height:100%; transition:width .9s cubic-bezier(.2,.8,.2,1); } .lb .good { background:var(--ok); } .lb .neutral { background:var(--muted); } .lb .gap { background:var(--warn); }
|
|
46
|
+
.lcell .lv { font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; }
|
|
47
|
+
.tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--rule); border:1px solid var(--rule); }
|
|
48
|
+
.tile { background:var(--panel); padding:5px 11px; text-align:left; border:0; border-radius:0; min-width:0; color:var(--fg); }
|
|
49
|
+
.tile .tn { font-size:20px; font-weight:600; line-height:1.1; font-variant-numeric:tabular-nums; display:block; }
|
|
50
|
+
.tile .tl { font-size:10.5px; color:var(--muted); display:block; margin-top:2px; }
|
|
51
|
+
.tile.ok .tn { color:var(--ok); } .tile.warn .tn { color:var(--warn); }
|
|
52
|
+
button.tile:hover { background:var(--panel2); } button.tile[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 0 -2px 0 var(--accent); }
|
|
53
|
+
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:0 0 6px; }
|
|
54
|
+
.tabs { display:flex; gap:0; margin-right:6px; } .tabs button { border-radius:0; } .tabs button:first-child { border-radius:6px 0 0 6px; } .tabs button:last-child { border-radius:0 6px 6px 0; }
|
|
55
|
+
.tabs button[aria-selected="true"] { border-color:var(--accent); font-weight:600; } .tabs button:disabled { opacity:.5; cursor:default; }
|
|
56
|
+
.seggrp { display:inline-flex; border:1px solid var(--edge); border-radius:6px; overflow:hidden; } .seggrp button.seg { border:0; border-radius:0; }
|
|
57
|
+
.toolbar input[type="search"] { min-width:280px; }
|
|
58
|
+
.workspace { display:block; height:max(540px, calc(100vh - 232px)); border:1px solid var(--edge); background:var(--panel); border-radius:12px; overflow:hidden; box-shadow:var(--shadow); }
|
|
59
|
+
.canvaswrap { position:relative; display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
|
|
60
|
+
#viewport { position:relative; flex:1; min-height:0; overflow:hidden; cursor:grab; touch-action:none; user-select:none;
|
|
61
|
+
background:radial-gradient(760px 380px at 4% 0%, var(--glow), transparent 65%), radial-gradient(640px 320px at 98% 100%, color-mix(in srgb, var(--ok) 16%, transparent), transparent 70%), linear-gradient(var(--grid) 1px, transparent 1px) 0 0/28px 28px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0/28px 28px, var(--bg); }
|
|
62
|
+
#viewport.panning { cursor:grabbing; } #viewport.hand .node { cursor:grab; }
|
|
63
|
+
#world { position:absolute; left:0; top:0; transform-origin:0 0; will-change:transform; }
|
|
64
|
+
#viewport.anim #world, #viewport.anim .hd, #viewport.anim .ph { transition:transform .28s cubic-bezier(.2,.8,.2,1), left .28s cubic-bezier(.2,.8,.2,1), width .28s cubic-bezier(.2,.8,.2,1); }
|
|
65
|
+
#edges { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
|
|
66
|
+
.band { position:absolute; border:1px solid var(--edge); border-radius:10px; background:color-mix(in srgb, var(--panel) 55%, transparent); transition:top .35s, height .35s, opacity .35s; pointer-events:none; }
|
|
67
|
+
.band.gap { border:1px dashed var(--warn); background:repeating-linear-gradient(135deg, transparent 0 9px, color-mix(in srgb, var(--warn) 7%, transparent) 9px 10px); }
|
|
68
|
+
.band.off { opacity:0; }
|
|
69
|
+
.bandlabel { position:absolute; left:12px; top:7px; display:flex; gap:8px; align-items:baseline; transform-origin:0 0; transform:scale(var(--inv,1)); white-space:nowrap; pointer-events:auto; cursor:pointer; z-index:3; }
|
|
70
|
+
.bandlabel b { font-size:13px; } .bandlabel .bd { font-size:10.5px; font-weight:700; padding:0 6px; border:1px solid currentColor; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
|
|
71
|
+
.bandlabel .bd.t-ok { color:var(--ok); } .bandlabel .bd.t-warn { color:var(--warn); } .bandlabel .bn { font-size:11.5px; color:var(--muted); }
|
|
72
|
+
.node { position:absolute; background:var(--panel); border:1px solid var(--edge); border-radius:8px; padding:5px 10px 7px; box-shadow:var(--shadow); transition:opacity .35s, transform .35s, box-shadow .2s, border-color .2s; cursor:pointer; z-index:2; }
|
|
73
|
+
.node.gateway { border:0; background:transparent; box-shadow:none; padding:0; border-radius:0; display:flex; align-items:center; justify-content:center; }
|
|
74
|
+
.node.gateway svg.gw { position:absolute; inset:0; width:100%; height:100%; overflow:visible; }
|
|
75
|
+
.node.gateway polygon { stroke-width:2; vector-effect:non-scaling-stroke; }
|
|
76
|
+
.node.gateway .gw-title { position:relative; width:58%; text-align:center; font-size:11px; font-weight:600; line-height:1.2; color:var(--fg); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow-wrap:anywhere; }
|
|
77
|
+
.node.gateway:hover, .node.gateway.lit, .node.gateway.sel { filter:drop-shadow(0 0 7px var(--accent)); }
|
|
78
|
+
.node.wide { overflow:hidden; }
|
|
79
|
+
.node:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--glow), var(--shadow); }
|
|
80
|
+
.node .t { display:flex; gap:4px; align-items:flex-start; font-weight:600; line-height:1.25; font-size:13px; }
|
|
81
|
+
.node .t .name { overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
|
82
|
+
.node .fold { flex:none; color:var(--accent); font-size:11px; }
|
|
83
|
+
.node .s { color:var(--muted); font-size:12px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
84
|
+
.ev { font-size:10.5px; font-weight:700; letter-spacing:.03em; }
|
|
85
|
+
.node.wide .t { font-weight:500; } .node.wide .s { white-space:normal; }
|
|
86
|
+
.node.ref { padding:4px 9px; border-style:dashed; box-shadow:none; background:color-mix(in srgb, var(--panel) 70%, transparent); } .node.ref .t { font-size:12px; } .node.ref .t .name { display:block; min-width:0; white-space:nowrap; text-overflow:ellipsis; } .node.ref .s { font-size:11px; }
|
|
87
|
+
.node.sel { outline:2px solid var(--accent); outline-offset:2px; } .node.dim { opacity:.28; } .node.off { opacity:0; visibility:hidden; transform:translateY(8px); pointer-events:none; }
|
|
88
|
+
.node .bar { position:absolute; left:0; right:0; bottom:0; display:flex; height:4px; overflow:hidden; border-radius:0 0 7px 7px; background:var(--panel2); } .node .bar i { display:block; height:100%; } .bar .good { background:var(--ok); } .bar .neutral { background:var(--muted); } .bar .gap { background:var(--warn); }
|
|
89
|
+
.newdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--accent); margin:5px 4px 0 0; flex:none; } body.alt .node.isnew .newdot { display:inline-block; }
|
|
90
|
+
body.alt .node.isnew.pulse { animation:newring 1.8s ease-out 1; }
|
|
91
|
+
.cor { position:absolute; top:5px; width:17px; height:17px; border-radius:50%; font-size:10px; display:flex; align-items:center; justify-content:center; font-weight:700; }
|
|
92
|
+
.cor.up { right:6px; background:var(--info-bg); color:var(--accent); } .cor.meas { right:6px; background:var(--ok-bg); color:var(--ok); } .cor.up + .cor.meas { right:27px; }
|
|
93
|
+
.node.hascor .t { padding-right:24px; } .node.hascor2 .t { padding-right:44px; }
|
|
94
|
+
#viewport.lod1 .node .s, #viewport.lod1 .bandlabel .bn { display:none; } #viewport.lod2 .node .cor, #viewport.lod2 .node.ref .s { display:none; }
|
|
95
|
+
path.edge { fill:none; stroke-width:1.8; transition:opacity .35s; vector-effect:non-scaling-stroke; }
|
|
96
|
+
path.edge.hot { stroke:var(--accent); stroke-width:2.4; opacity:1; stroke-dasharray:6 8; animation:flowhot .9s linear infinite; filter:drop-shadow(0 0 5px var(--accent)); }
|
|
97
|
+
path.edge.dim { opacity:.07; filter:none; } path.edge.off { opacity:0; }
|
|
98
|
+
.elabel { position:absolute; font-size:11px; padding:0 6px; border-radius:9px; background:var(--panel); border:1px solid var(--edge); color:var(--muted); white-space:nowrap; transform:translate(-50%,-50%); opacity:0; transition:opacity .35s; z-index:4; } .elabel.show { opacity:1; } .elabel.off { display:none; }
|
|
99
|
+
#cols { position:absolute; left:0; right:0; top:0; height:44px; pointer-events:none; z-index:20; background:linear-gradient(var(--bg) 62%, transparent); }
|
|
100
|
+
#cols .ph { position:absolute; top:3px; height:16px; font-size:10px; letter-spacing:.14em; font-weight:700; color:var(--accent); text-transform:uppercase; display:flex; align-items:center; gap:6px; overflow:hidden; }
|
|
101
|
+
#cols .ph::before { content:""; height:1px; background:var(--accent); width:8px; opacity:.7; } #cols .ph::after { content:"›"; margin-left:auto; padding-right:3px; opacity:.8; flex:none; }
|
|
102
|
+
#cols .ph span { flex:none; } #cols .ph i { flex:1; height:1px; background:var(--accent); opacity:.4; }
|
|
103
|
+
#cols .hd { position:absolute; top:22px; font-size:10.5px; letter-spacing:.08em; font-weight:700; color:var(--muted); text-transform:uppercase; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #cols .hd b { color:var(--fg); }
|
|
104
|
+
#tools { position:absolute; left:10px; top:58px; z-index:30; display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--edge); box-shadow:var(--shadow); }
|
|
105
|
+
#tools button { width:34px; height:32px; padding:0; border:0; border-radius:0; border-bottom:1px solid var(--rule); display:flex; align-items:center; justify-content:center; background:transparent; }
|
|
106
|
+
#tools button:last-child { border-bottom:0; } #tools button:hover { background:var(--panel2); color:var(--accent); box-shadow:none; } #tools button[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 3px 0 0 var(--accent); color:var(--accent); }
|
|
107
|
+
#tools svg { width:17px; height:17px; stroke:currentColor; fill:none; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } #tools .sep { height:1px; background:var(--edge); margin:0; }
|
|
108
|
+
#zoomLabel { position:absolute; left:10px; top:auto; z-index:30; font:11px var(--mono); color:var(--muted); background:var(--panel); border:1px solid var(--edge); padding:1px 6px; }
|
|
109
|
+
.keyline { display:flex; flex-wrap:wrap; align-items:center; gap:6px 15px; padding:6px 13px; border-top:1px solid var(--rule); background:var(--panel2); font-size:10.5px; color:var(--muted); } .keyline svg { vertical-align:middle; margin-right:6px; }
|
|
110
|
+
.insp { display:flex; flex-direction:column; background:var(--panel); min-height:0; min-width:0; font-family:var(--mono); position:absolute; top:0; right:0; bottom:0; width:340px; z-index:40; border-left:1px solid var(--edge); box-shadow:-14px 0 34px rgba(0,0,0,.16), -1px 0 0 var(--glow); transform:translateX(104%); visibility:hidden; transition:transform .3s cubic-bezier(.2,.8,.2,1), visibility 0s .3s; }
|
|
111
|
+
.insp.open { transform:none; visibility:visible; transition:transform .3s cubic-bezier(.2,.8,.2,1), visibility 0s; }
|
|
112
|
+
.insp-h { display:flex; align-items:center; justify-content:space-between; padding:0 12px 0 15px; height:39px; border-bottom:1px solid var(--rule); background:var(--panel2); font-size:10.5px; color:var(--muted); letter-spacing:.02em; flex:none; }
|
|
113
|
+
.insp-h button { padding:0 8px; height:24px; font-size:11px; }
|
|
114
|
+
.counts { display:flex; border-bottom:1px solid var(--rule); flex:none; }
|
|
115
|
+
.counts .cbtn { flex:1; padding:9px 6px 8px; border:0; border-right:1px solid var(--rule); border-radius:0; text-align:center; background:transparent; }
|
|
116
|
+
.counts .cbtn:last-child { border-right:0; } .counts .cbtn:hover { background:var(--panel2); } .counts .cbtn[aria-pressed="true"] { background:var(--panel2); box-shadow:inset 0 -2px 0 var(--accent); } .counts .cbtn:disabled { opacity:.45; cursor:default; }
|
|
117
|
+
.counts .cn { font-size:20px; font-weight:600; display:block; line-height:1.1; font-variant-numeric:tabular-nums; } .cn--up { color:var(--up); } .cn--down { color:var(--down); } .counts .cl { font-size:9.5px; color:var(--muted); display:block; margin-top:3px; }
|
|
118
|
+
.insp-b { padding:14px 15px 18px; min-width:0; flex:1 1 auto; overflow:auto; min-height:0; }
|
|
119
|
+
.insp-sec { font-size:10.5px; color:var(--muted); margin:15px 0 0; padding-top:10px; border-top:1px solid var(--rule); } .insp-sec:first-child { margin-top:0; padding-top:0; border-top:0; } .insp-sec b { color:var(--fg); font-weight:600; }
|
|
120
|
+
.nname { font-size:15px; font-weight:600; line-height:1.25; word-break:break-word; margin:0 0 3px; } .nkind { font-size:10.5px; color:var(--muted); margin-bottom:9px; }
|
|
121
|
+
.pillrow { display:flex; gap:6px; margin-bottom:11px; flex-wrap:wrap; } .pill { font-size:9.5px; letter-spacing:.04em; text-transform:uppercase; padding:2px 7px; border:1px solid currentColor; }
|
|
122
|
+
.pill.ok { color:var(--ok); } .pill.warn { color:var(--warn); } .pill.info { color:var(--accent); } .pill.muted { color:var(--muted); }
|
|
123
|
+
.note { margin:0 0 12px; font-size:12.5px; color:var(--muted); line-height:1.5; } .note code, .insp code { font-family:var(--mono); font-size:11.5px; background:var(--panel2); padding:1px 4px; }
|
|
124
|
+
.meta { width:100%; border-collapse:collapse; font-size:11.5px; } .meta th, .meta td { text-align:left; padding:4px 0; border-top:1px solid var(--rule); vertical-align:top; } .meta th { color:var(--muted); font-weight:400; width:42%; }
|
|
125
|
+
.drill { margin:0 -15px; } .drill .dr { display:flex; align-items:baseline; gap:8px; width:100%; text-align:left; border:0; border-top:1px solid var(--rule); border-radius:0; background:transparent; padding:6px 15px; font:inherit; }
|
|
126
|
+
button.dr:hover { background:var(--panel2); } .drill .dbar { width:3px; align-self:stretch; flex:none; } .drill .dtx { min-width:0; flex:1; } .drill .dn { font-size:12.5px; display:block; word-break:break-word; line-height:1.3; } .drill .dk { font-size:10px; color:var(--muted); display:block; margin-top:1px; }
|
|
127
|
+
.drill-hint { font-size:10.5px; color:var(--muted); margin:10px 0 0; } .insp input[type="search"] { width:100%; margin:4px 0 6px; font-size:12px; }
|
|
128
|
+
.insp .acts { display:flex; gap:6px; margin:10px 0 0; flex-wrap:wrap; } .insp .acts button { font-size:11.5px; }
|
|
129
|
+
.ledger { margin:12px 0 0; border-top:1px solid var(--rule); padding-top:10px; } .ledger h4 { margin:0 0 6px; font-size:10.5px; color:var(--muted); font-weight:400; } .ledger p { margin:0 0 4px; font-size:12px; color:var(--muted); line-height:1.55; } .ledger p b { color:var(--fg); }
|
|
130
|
+
.marker { position:absolute; z-index:90; width:24px; height:24px; border-radius:50%; background:#8250df; color:#fff; font-weight:700; font-size:12px; display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 3px rgba(130,80,223,.3), var(--shadow); cursor:pointer; border:0; padding:0; } .marker:hover, .marker.on { box-shadow:0 0 0 5px rgba(130,80,223,.45); transform:scale(1.1); }
|
|
131
|
+
.anchored { outline:2px dashed #8250df; outline-offset:3px; }
|
|
132
|
+
#drawer { position:fixed; top:56px; left:0; bottom:0; width:380px; background:var(--panel); border-right:1px solid var(--edge); box-shadow:var(--shadow); z-index:80; transform:translateX(-100%); transition:transform .3s cubic-bezier(.2,.8,.2,1); overflow:auto; padding:14px 16px; } body.notes #drawer { transform:none; } main { transition:margin-left .3s cubic-bezier(.2,.8,.2,1); } body.notes main { margin-left:380px; } @media (max-width:1100px) { body.notes main { margin-left:0; } }
|
|
133
|
+
#drawer h2 { margin:0 0 8px; font-size:15px; } #drawer .ncard { border:1px solid var(--edge); padding:8px 10px; margin:8px 0; font-size:12.5px; } #drawer .ncard.on { border-color:#8250df; box-shadow:0 0 0 3px rgba(130,80,223,.25); }
|
|
134
|
+
#drawer .ncard b { display:flex; gap:8px; align-items:center; margin-bottom:2px; font-size:13px; } #drawer .ncard b span { background:#8250df; color:#fff; width:20px; height:20px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; font-size:11px; flex:none; }
|
|
135
|
+
details.tableview, details.spec { margin:12px 0; background:var(--panel); border:1px solid var(--edge); padding:8px 14px; } details summary { cursor:pointer; color:var(--accent); font-weight:600; }
|
|
136
|
+
table.sources { width:100%; border-collapse:collapse; margin-top:8px; font-size:13px; } table.sources th, table.sources td { text-align:left; padding:5px 10px; border-bottom:1px solid var(--rule); vertical-align:top; } table.sources th { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.05em; }
|
|
137
|
+
.spec h3 { margin:14px 0 4px; font-size:14px; } .spec h4 { margin:10px 0 2px; font-size:13px; } .spec ul { margin:4px 0 4px 18px; padding:0; } .spec li { margin:2px 0; } .spec .task { border:1px solid var(--edge); padding:6px 12px; margin:8px 0; }
|
|
138
|
+
.spec code { font-family:var(--mono); font-size:12px; background:var(--panel2); padding:0 4px; } .specbtns { display:flex; gap:8px; margin:8px 0; flex-wrap:wrap; }
|
|
139
|
+
footer { color:var(--muted); font-size:12.5px; padding:0 14px 28px; }
|
|
140
|
+
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
|
|
141
|
+
:fullscreen.workspace { height:100vh; } .workspace.fs { position:fixed; inset:0; z-index:95; height:100vh; }
|
|
142
|
+
button.danger { background:var(--crit); border-color:var(--crit); color:#fff; font-weight:600; } button.danger:hover { box-shadow:0 0 0 3px var(--crit-bg); }
|
|
143
|
+
.layerstrip, .tiles { box-shadow:var(--shadow); } .lcell:hover { background:var(--panel2); } button.tile:hover { box-shadow:inset 0 0 0 1px var(--accent), 0 0 0 3px var(--glow); position:relative; z-index:1; }
|
|
144
|
+
.bandlabel:hover b { color:var(--accent); }
|
|
145
|
+
.node:hover { transform:translateY(-2px); }
|
|
146
|
+
.node.lit { border-color:var(--accent); box-shadow:0 0 0 2px var(--glow), 0 0 18px var(--glow), var(--shadow); }
|
|
147
|
+
.node.sel { animation:selpulse 2.4s ease-out infinite; }
|
|
148
|
+
.node.fail { border-color:var(--crit); background:var(--crit-bg); box-shadow:0 0 0 2px var(--crit-bg), 0 0 20px color-mix(in srgb, var(--crit) 45%, transparent); animation:failflash .9s ease-out 1; } .node.fail .s { color:var(--crit); font-weight:600; }
|
|
149
|
+
path.trail { fill:none; stroke:var(--accent); vector-effect:none; stroke-width:calc(3.4px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 6px var(--accent)); opacity:.95; }
|
|
150
|
+
path.ripple { fill:none; stroke:var(--crit); vector-effect:none; stroke-width:calc(3.6px / var(--k, 1)); stroke-linecap:round; filter:drop-shadow(0 0 7px var(--crit)); }
|
|
151
|
+
.intro .band { animation:fadein .6s both; } .intro .node { animation:rise .55s both; animation-delay:calc(var(--c, 0) * 60ms + 150ms); } .intro #edges { animation:fadein 1s .5s both; }
|
|
152
|
+
.intro .lcell, .intro .tile { animation:rise .5s both; animation-delay:calc(var(--i, 0) * 55ms); }
|
|
153
|
+
#viewport:focus-visible { outline-offset:-2px; }
|
|
154
|
+
@keyframes fadein { from { opacity:0; } to { opacity:1; } }
|
|
155
|
+
@keyframes flowd { to { stroke-dashoffset:-26; } } @keyframes flowc { to { stroke-dashoffset:-27; } } @keyframes flowhot { to { stroke-dashoffset:-28; } }
|
|
156
|
+
@keyframes selpulse { 0% { box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent), 0 0 18px var(--glow); } 80%, 100% { box-shadow:0 0 0 14px transparent, 0 0 18px var(--glow); } }
|
|
157
|
+
@keyframes breathe { 0%, 100% { box-shadow:0 0 0 0 transparent, var(--shadow); } 50% { box-shadow:0 0 16px 1px color-mix(in srgb, var(--breath, var(--warn)) 40%, transparent), var(--shadow); } }
|
|
158
|
+
@keyframes failflash { 0% { box-shadow:0 0 0 0 color-mix(in srgb, var(--crit) 60%, transparent); } 100% { box-shadow:0 0 0 16px transparent; } }
|
|
159
|
+
@keyframes newring { 0% { box-shadow:0 0 0 0 var(--accent); } 100% { box-shadow:0 0 0 14px transparent; } }
|
|
160
|
+
@keyframes flow { to { stroke-dashoffset:-28; } } @keyframes rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } @keyframes ring { to { box-shadow:0 0 0 14px transparent; } }
|
|
161
|
+
@keyframes beat { 0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--ok) 60%, transparent);} 70%,100%{box-shadow:0 0 0 9px transparent;} }
|
|
162
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none !important; transition:none !important; } path.edge.hot { stroke-dasharray:none; } }
|
|
163
|
+
@media (max-width: 1100px) { .summary { grid-template-columns:1fr; } .workspace { height:auto; } .workspace .canvaswrap { height:75vh; } .insp { width:min(340px, 92%); } #drawer { width:100%; } .layerstrip { grid-template-columns:repeat(3,1fr) !important; } }
|
|
164
|
+
#banner:not([hidden]) { margin:0 14px 10px; padding:8px 12px; color:var(--crit); background:var(--crit-bg); border:1px solid var(--crit); font-size:12px; }
|
|
165
|
+
#playbar:not([hidden]) { display:flex; flex-wrap:wrap; align-items:center; gap:7px; padding:7px 12px; border-top:1px solid var(--rule); background:var(--panel); font-size:12px; }
|
|
166
|
+
#playbar select, #playbar button { font-size:11px; }
|
|
167
|
+
#pbScrub { flex:1 1 140px; min-width:90px; }
|
|
168
|
+
#pbCaption { flex:1 1 100%; color:var(--fg); }
|
|
169
|
+
#pbCounter { font:11px var(--mono); color:var(--fg); }
|
|
170
|
+
#pbCounter.warn { color:var(--warn); } #pbCounter.breach { color:var(--crit); font-weight:700; }
|
|
171
|
+
#pbChoices { display:flex; gap:5px; flex-wrap:wrap; } #pbChoices:empty { display:none; }
|
|
172
|
+
#pbChoices .suggested { border-color:var(--accent); box-shadow:inset 0 -2px 0 var(--accent); }
|
|
173
|
+
circle.token { fill:var(--accent); stroke:var(--panel); stroke-width:2; filter:drop-shadow(0 0 5px var(--accent)); pointer-events:none; }
|
|
174
|
+
path.edge.playing { stroke:var(--accent); stroke-width:3; filter:drop-shadow(0 0 5px var(--accent)); opacity:1; }
|
|
175
|
+
path.edge.visited { stroke:var(--accent); opacity:.7; }
|
|
176
|
+
.node.playing { outline:2px solid var(--accent); outline-offset:2px; }
|
|
177
|
+
/* The pulse runs only while the player really plays; a paused step keeps the plain outline. */
|
|
178
|
+
body.playing .node.playing { animation:selpulse 1.8s ease-out infinite; }
|
|
179
|
+
.node.visited::after { content:"✓"; position:absolute; right:3px; bottom:1px; color:var(--accent); font-size:11px; }
|
|
180
|
+
.node.updated { animation:newring 1.2s ease-out 1; }
|
|
181
|
+
.node.added { animation:rise 1.2s ease-out 1; }
|
|
182
|
+
.node .t .name { min-width:0; flex:1 1 auto; }
|
|
183
|
+
.node .metric-badge { flex:none; max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; position:relative; padding-bottom:3px; text-align:right; font:11px var(--mono); color:var(--accent); }
|
|
184
|
+
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
185
|
+
.node .s.has-spark { padding-right:60px; }
|
|
186
|
+
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
187
|
+
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
188
|
+
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
189
|
+
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|
|
190
|
+
.metrics svg { max-width:100%; margin-top:8px; } .metric-range { display:flex; justify-content:space-between; width:300px; max-width:100%; font:10px var(--mono); color:var(--muted); }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { relatives, connected, withinHops } from "./trace.ts";
|
|
4
|
+
|
|
5
|
+
const edges = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "x", to: "c" }, { from: "c", to: "d" }];
|
|
6
|
+
const id = (s: string) => s;
|
|
7
|
+
|
|
8
|
+
test("downstream follows links forward", () => assert.deepEqual([...relatives(edges, id, "b", "down")].sort(), ["b", "c", "d"]));
|
|
9
|
+
test("upstream follows links backward", () => assert.deepEqual([...relatives(edges, id, "c", "up")].sort(), ["a", "b", "c", "x"]));
|
|
10
|
+
test("connected is up plus down", () => assert.deepEqual([...connected(edges, id, "b")].sort(), ["a", "b", "c", "d"]));
|
|
11
|
+
test("a chip is an alias of its real node", () => {
|
|
12
|
+
const canon = (s: string) => (s === "b2" ? "b" : s);
|
|
13
|
+
const e = [...edges, { from: "b2", to: "z" }];
|
|
14
|
+
assert.ok(relatives(e, canon, "b", "down").has("z"));
|
|
15
|
+
});
|
|
16
|
+
test("a cycle terminates", () => {
|
|
17
|
+
const cyc = [{ from: "a", to: "b" }, { from: "b", to: "a" }];
|
|
18
|
+
assert.deepEqual([...relatives(cyc, id, "a", "down")].sort(), ["a", "b"]);
|
|
19
|
+
});
|
|
20
|
+
test("a loop-back does not break relatives", () => {
|
|
21
|
+
const cycle = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "c", to: "a" }];
|
|
22
|
+
assert.deepEqual([...relatives(cycle, id, "a", "down")].sort(), ["a", "b", "c"]);
|
|
23
|
+
assert.deepEqual([...relatives(cycle, id, "c", "up")].sort(), ["a", "b", "c"]);
|
|
24
|
+
});
|
|
25
|
+
test("an unknown start returns only itself", () => assert.deepEqual([...relatives(edges, id, "nope", "down")], ["nope"]));
|
|
26
|
+
|
|
27
|
+
test("withinHops includes starts and walks links in either direction", () => {
|
|
28
|
+
assert.deepEqual([...withinHops(edges, id, ["b"], 0)].sort(), ["b"]);
|
|
29
|
+
assert.deepEqual([...withinHops(edges, id, ["b"], 1)].sort(), ["a", "b", "c"]);
|
|
30
|
+
assert.deepEqual([...withinHops(edges, id, ["b"], 2)].sort(), ["a", "b", "c", "d", "x"]);
|
|
31
|
+
});
|
|
32
|
+
test("withinHops canonicalizes aliases and joins multiple starts", () => {
|
|
33
|
+
const canon = (s: string) => s === "b2" ? "b" : s;
|
|
34
|
+
assert.deepEqual([...withinHops(edges, canon, ["b2", "x"], 1)].sort(), ["a", "b", "c", "x"]);
|
|
35
|
+
});
|
|
36
|
+
test("withinHops terminates on cycles and treats a negative radius as zero", () => {
|
|
37
|
+
const cycle = [{ from: "a", to: "b" }, { from: "b", to: "a" }];
|
|
38
|
+
assert.deepEqual([...withinHops(cycle, id, ["a"], 9)].sort(), ["a", "b"]);
|
|
39
|
+
assert.deepEqual([...withinHops(cycle, id, ["a"], -1)], ["a"]);
|
|
40
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Path tracing over links. A portal chip is an alias of its real node: pass canonOf to map ids.
|
|
2
|
+
|
|
3
|
+
export interface Edge { from: string; to: string }
|
|
4
|
+
|
|
5
|
+
export function relatives(edges: Edge[], canonOf: (id: string) => string, start: string, dir: "up" | "down"): Set<string> {
|
|
6
|
+
const set = new Set<string>([canonOf(start)]);
|
|
7
|
+
let grew = true;
|
|
8
|
+
while (grew) {
|
|
9
|
+
grew = false;
|
|
10
|
+
for (const e of edges) {
|
|
11
|
+
const a = canonOf(e.from);
|
|
12
|
+
const b = canonOf(e.to);
|
|
13
|
+
if (dir === "up" && set.has(b) && !set.has(a)) { set.add(a); grew = true; }
|
|
14
|
+
if (dir === "down" && set.has(a) && !set.has(b)) { set.add(b); grew = true; }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function connected(edges: Edge[], canonOf: (id: string) => string, start: string): Set<string> {
|
|
21
|
+
return new Set([...relatives(edges, canonOf, start, "up"), ...relatives(edges, canonOf, start, "down")]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Undirected breadth-first radius around one or more nodes. */
|
|
25
|
+
export function withinHops(edges: Edge[], canonOf: (id: string) => string, starts: string[], n: number): Set<string> {
|
|
26
|
+
const found = new Set(starts.map(canonOf));
|
|
27
|
+
const radius = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
|
28
|
+
let frontier = [...found];
|
|
29
|
+
for (let hop = 0; hop < radius && frontier.length; hop++) {
|
|
30
|
+
const next: string[] = [];
|
|
31
|
+
const current = new Set(frontier);
|
|
32
|
+
for (const edge of edges) {
|
|
33
|
+
const a = canonOf(edge.from), b = canonOf(edge.to);
|
|
34
|
+
if (current.has(a) && !found.has(b)) { found.add(b); next.push(b); }
|
|
35
|
+
if (current.has(b) && !found.has(a)) { found.add(a); next.push(a); }
|
|
36
|
+
}
|
|
37
|
+
frontier = next;
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { zoomAt, fitRect, fitWidth, clampPan, clampK, K_MIN, K_MAX } from "./viewport.ts";
|
|
4
|
+
|
|
5
|
+
const close = (a: number, b: number, e = 1e-6) => assert.ok(Math.abs(a - b) < e, `${a} vs ${b}`);
|
|
6
|
+
|
|
7
|
+
test("zoomAt keeps the point under the cursor fixed", () => {
|
|
8
|
+
const v = { x: 40, y: -20, k: 0.8 }, cx = 300, cy = 200;
|
|
9
|
+
const w = { x: (cx - v.x) / v.k, y: (cy - v.y) / v.k };
|
|
10
|
+
const z = zoomAt(v, 1.7, cx, cy);
|
|
11
|
+
close(z.x + w.x * z.k, cx);
|
|
12
|
+
close(z.y + w.y * z.k, cy);
|
|
13
|
+
});
|
|
14
|
+
test("zoom is clamped to 0.25 and 2.5", () => {
|
|
15
|
+
assert.equal(zoomAt({ x: 0, y: 0, k: 2.4 }, 10, 0, 0).k, K_MAX);
|
|
16
|
+
assert.equal(zoomAt({ x: 0, y: 0, k: 0.3 }, 0.01, 0, 0).k, K_MIN);
|
|
17
|
+
assert.ok(Number.isFinite(clampK(NaN)));
|
|
18
|
+
});
|
|
19
|
+
test("zoomAt returns finite numbers even from a bad scale", () => {
|
|
20
|
+
for (const k of [0, NaN, -1]) {
|
|
21
|
+
const z = zoomAt({ x: 5, y: 5, k }, 1.2, 100, 100);
|
|
22
|
+
assert.ok([z.x, z.y, z.k].every(Number.isFinite), `k=${k}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
test("fitRect centres, never enlarges above 1 and never goes below 0.25", () => {
|
|
26
|
+
const v = fitRect({ x: 0, y: 0, w: 100, h: 50 }, { w: 1000, h: 800 }, 28, 52);
|
|
27
|
+
assert.equal(v.k, 1);
|
|
28
|
+
const w = fitRect({ x: 0, y: 0, w: 100000, h: 50 }, { w: 1000, h: 800 }, 28, 52);
|
|
29
|
+
assert.equal(w.k, K_MIN);
|
|
30
|
+
});
|
|
31
|
+
test("fitRect gives finite numbers for a zero-size container (review focus 5)", () => {
|
|
32
|
+
const v = fitRect({ x: 0, y: 0, w: 500, h: 400 }, { w: 0, h: 0 }, 28, 52);
|
|
33
|
+
assert.ok([v.x, v.y, v.k].every(Number.isFinite));
|
|
34
|
+
});
|
|
35
|
+
test("fitRect gives finite numbers for an empty rectangle (review focus 2)", () => {
|
|
36
|
+
const v = fitRect({ x: 0, y: 0, w: 0, h: 0 }, { w: 1000, h: 800 }, 28, 52);
|
|
37
|
+
assert.ok([v.x, v.y, v.k].every(Number.isFinite));
|
|
38
|
+
});
|
|
39
|
+
test("fitWidth fits the width and starts at the top", () => {
|
|
40
|
+
const v = fitWidth({ x: 10, y: 20, w: 2000, h: 5000 }, { w: 1056, h: 600 }, 28, 52);
|
|
41
|
+
close(v.k, 1000 / 2000);
|
|
42
|
+
close(v.x, 28 - 10 * v.k);
|
|
43
|
+
close(v.y, 52 - 20 * v.k);
|
|
44
|
+
});
|
|
45
|
+
test("fitWidth is finite for an empty rectangle and a zero width", () => {
|
|
46
|
+
const a = fitWidth({ x: 0, y: 0, w: 0, h: 0 }, { w: 800, h: 600 }, 28, 52);
|
|
47
|
+
const b = fitWidth({ x: 0, y: 0, w: 900, h: 400 }, { w: 0, h: 0 }, 28, 52);
|
|
48
|
+
assert.ok([a.x, a.y, a.k, b.x, b.y, b.k].every(Number.isFinite));
|
|
49
|
+
});
|
|
50
|
+
test("clampPan keeps 80 px of the graph visible", () => {
|
|
51
|
+
const b = { x: 0, y: 0, w: 1000, h: 800 }, size = { w: 1000, h: 700 };
|
|
52
|
+
const far = clampPan({ x: -99999, y: 99999, k: 1 }, b, size);
|
|
53
|
+
assert.equal(far.x, 80 - 1000);
|
|
54
|
+
assert.equal(far.y, 700 - 80);
|
|
55
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// View maths for the pan and zoom canvas. Pure functions, no DOM. The world is moved with
|
|
2
|
+
// transform: translate(x, y) scale(k); a world point (wx, wy) is at screen (x + wx * k, y + wy * k).
|
|
3
|
+
|
|
4
|
+
export interface View { x: number; y: number; k: number }
|
|
5
|
+
export interface Rect { x: number; y: number; w: number; h: number }
|
|
6
|
+
export interface Size { w: number; h: number }
|
|
7
|
+
|
|
8
|
+
export const K_MIN = 0.25;
|
|
9
|
+
export const K_MAX = 2.5;
|
|
10
|
+
|
|
11
|
+
export const clampK = (k: number): number => (Number.isFinite(k) ? Math.min(K_MAX, Math.max(K_MIN, k)) : 1);
|
|
12
|
+
|
|
13
|
+
/** Zoom by a factor and keep the world point under (cx, cy) fixed. */
|
|
14
|
+
export function zoomAt(v: View, factor: number, cx: number, cy: number): View {
|
|
15
|
+
const base = Number.isFinite(v.k) && v.k > 0 ? v.k : 1;
|
|
16
|
+
const k = clampK(base * factor);
|
|
17
|
+
const r = k / base;
|
|
18
|
+
return { k, x: cx - (cx - v.x) * r, y: cy - (cy - v.y) * r };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Fit a world rectangle into the viewport. Never enlarges above 1. `top` reserves room for pinned headers. */
|
|
22
|
+
export function fitRect(rect: Rect, size: Size, pad: number, top: number): View {
|
|
23
|
+
if (!(rect.w > 0 && rect.h > 0)) return { x: 0, y: 0, k: 1 };
|
|
24
|
+
const aw = Math.max(1, size.w - 2 * pad);
|
|
25
|
+
const ah = Math.max(1, size.h - 2 * pad - top);
|
|
26
|
+
const k = Math.min(1, clampK(Math.min(aw / rect.w, ah / rect.h)));
|
|
27
|
+
return { k, x: pad + (aw - rect.w * k) / 2 - rect.x * k, y: pad + top + (ah - rect.h * k) / 2 - rect.y * k };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fit the width and start at the top: the readable first view. */
|
|
31
|
+
export function fitWidth(rect: Rect, size: Size, pad: number, top: number): View {
|
|
32
|
+
if (!(rect.w > 0)) return { x: 0, y: 0, k: 1 };
|
|
33
|
+
const k = Math.min(1, clampK((Math.max(1, size.w) - 2 * pad) / rect.w));
|
|
34
|
+
return { k, x: pad - rect.x * k, y: top - rect.y * k };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Keep at least 80 px of the graph on screen so the reader cannot lose it. */
|
|
38
|
+
export function clampPan(v: View, bounds: Rect, size: Size): View {
|
|
39
|
+
const keep = 80;
|
|
40
|
+
const minX = keep - (bounds.x + bounds.w) * v.k;
|
|
41
|
+
const maxX = size.w - keep - bounds.x * v.k;
|
|
42
|
+
const minY = keep - (bounds.y + bounds.h) * v.k;
|
|
43
|
+
const maxY = size.h - keep - bounds.y * v.k;
|
|
44
|
+
return { k: v.k, x: Math.min(maxX, Math.max(minX, v.x)), y: Math.min(maxY, Math.max(minY, v.y)) };
|
|
45
|
+
}
|