@visualli/react 0.1.7 → 0.2.0

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 (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -0,0 +1,154 @@
1
+ // ─── Design-system runtime ───────────────────────────────────────────────────
2
+ //
3
+ // Injects the design system's CSS (tokens + spec components) once per page,
4
+ // loads its fonts, and tracks devicePixelRatio for the canvas.
5
+ //
6
+ // Dependencies: ../generated/specCss.ts (generated from design-system/).
7
+
8
+ import { useSyncExternalStore } from 'react';
9
+ import { TOKENS, TYPE_STYLES } from '@visualli/core';
10
+ import {
11
+ SPEC_BUNDLED_FONTS,
12
+ SPEC_COMPONENT_CSS,
13
+ SPEC_DESIGN_SYSTEM_VERSION,
14
+ SPEC_FONTS_URL,
15
+ SPEC_TOKENS_CSS,
16
+ } from '../generated/specCss';
17
+
18
+ const STYLE_ID = 'visualli-design-system';
19
+
20
+ /**
21
+ * Responsive rules the SDK adds on top of the spec CSS. The design system defines the
22
+ * touch variant (`.is-touch`, bottom sheet); phone / tablet / desktop widths are
23
+ * handled with the container-width classes the canvas sets (`is-compact` < 560px,
24
+ * `is-medium` < 900px). Only design-system custom properties are used.
25
+ */
26
+ const LAYOUT_CSS = `
27
+ .vi-map__ctrls { transition: bottom var(--duration-quick) var(--ease-standard); } /* controlsPosition 'bottom-right': rising above the sheet */
28
+ .vi-map.is-compact .vi-trail li:not(:first-child):not(.is-current):not(:nth-last-child(2)) { display: none; }
29
+ .vi-map.is-compact .vi-trail button span { max-width: 40vw; max-width: 40cqw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ .vi-map.is-compact .vi-fact:not(.vi-fact--sheet) { width: min(300px, calc(100cqw - 2 * var(--space-4))); }
31
+ .vi-map.is-touch .vi-trail button { min-height: 32px; }
32
+ `;
33
+ const FONTS_ID = 'visualli-design-system-fonts';
34
+ const CAVEAT_ID = 'visualli-design-system-caveat';
35
+ // Injected by tsup (`define`) from package.json so it can never drift from the published version.
36
+ declare const __SDK_VERSION__: string;
37
+ const SDK_VERSION = typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : 'latest';
38
+
39
+ export interface DesignSystemAssets {
40
+ /**
41
+ * Where the bundled Caveat font file is served from (URL of the directory that
42
+ * contains `Caveat-Variable.ttf`). Defaults to the copy shipped inside the npm
43
+ * package, via jsDelivr.
44
+ */
45
+ fontBaseUrl?: string;
46
+ /** Set false to skip the Google Fonts stylesheet (Kalam + Atkinson Hyperlegible), e.g. when self-hosting them. */
47
+ loadWebFonts?: boolean;
48
+ }
49
+
50
+ let webFontsSheet: Promise<void> = Promise.resolve();
51
+
52
+ /**
53
+ * Inject the spec CSS and font declarations once. Safe to call repeatedly and on the server (no-op).
54
+ * The returned promise settles when the web-font stylesheet has loaded (its @font-face rules must exist
55
+ * before document.fonts.load() can fetch anything).
56
+ */
57
+ export function ensureDesignSystemStyles(assets: DesignSystemAssets = {}): Promise<void> {
58
+ if (typeof document === 'undefined') return Promise.resolve();
59
+
60
+ if (!document.getElementById(STYLE_ID)) {
61
+ const style = document.createElement('style');
62
+ style.id = STYLE_ID;
63
+ style.setAttribute('data-design-system', SPEC_DESIGN_SYSTEM_VERSION);
64
+ // Component CSS goes first so token rules (same specificity) win ties, exactly like index.css load order.
65
+ style.textContent = `${SPEC_TOKENS_CSS}\n${SPEC_COMPONENT_CSS}\n${LAYOUT_CSS}`;
66
+ document.head.appendChild(style);
67
+ }
68
+
69
+ if (assets.loadWebFonts !== false && SPEC_FONTS_URL && !document.getElementById(FONTS_ID)) {
70
+ const link = document.createElement('link');
71
+ link.id = FONTS_ID;
72
+ link.rel = 'stylesheet';
73
+ link.href = SPEC_FONTS_URL;
74
+ webFontsSheet = new Promise<void>((resolve) => {
75
+ link.addEventListener('load', () => resolve(), { once: true });
76
+ link.addEventListener('error', () => resolve(), { once: true });
77
+ });
78
+ document.head.appendChild(link);
79
+ }
80
+
81
+ if (!document.getElementById(CAVEAT_ID)) {
82
+ const base = (assets.fontBaseUrl ?? `https://cdn.jsdelivr.net/npm/@visualli/react@${SDK_VERSION}/fonts`).replace(/\/$/, '');
83
+ const style = document.createElement('style');
84
+ style.id = CAVEAT_ID;
85
+ style.textContent = SPEC_BUNDLED_FONTS
86
+ .map((f) => `@font-face { font-family: "${f.family}"; src: url("${base}/${f.file}"); font-weight: ${f.weight}; font-display: swap; }`)
87
+ .join('\n');
88
+ document.head.appendChild(style);
89
+ }
90
+ return webFontsSheet;
91
+ }
92
+
93
+ // ── Fonts ─────────────────────────────────────────────────────────────────────
94
+
95
+ /** First family of a font stack token, e.g. `font-hand` -> its handwriting face. */
96
+ const firstFamily = (stack: string): string => stack.split(',')[0]!.trim();
97
+
98
+ /**
99
+ * Faces the canvas draws with, derived from the design-system tokens and type styles.
100
+ * Loading them up front means the first frame already uses the right fonts.
101
+ */
102
+ const CANVAS_FONT_FACES = (['node-label', 'edge-label', 'body'] as const).map((style) => {
103
+ const t = TYPE_STYLES[style];
104
+ return `${t.weight} ${t.size}px ${firstFamily(TOKENS.light[t.family]!)}`;
105
+ });
106
+
107
+ let fontsEpochValue = 0;
108
+ const fontListeners = new Set<() => void>();
109
+
110
+ /** Bumps whenever web fonts finish loading; text measurement caches are keyed on it. */
111
+ export const fontsEpoch = (): number => fontsEpochValue;
112
+
113
+ /** React hook: re-renders when fonts finish loading (so canvases redraw with the real faces). */
114
+ export function useFontsEpoch(): number {
115
+ return useSyncExternalStore(onFontsChange, fontsEpoch, () => 0);
116
+ }
117
+ export const onFontsChange = (cb: () => void): (() => void) => { fontListeners.add(cb); return () => fontListeners.delete(cb); };
118
+
119
+ /**
120
+ * Resolves once the canvas fonts are usable (or after `timeoutMs`, so an
121
+ * offline reader still gets a map in the fallback faces).
122
+ */
123
+ export function loadCanvasFonts(sheet: Promise<void> = Promise.resolve(), timeoutMs = 3000): Promise<void> {
124
+ if (typeof document === 'undefined' || !('fonts' in document)) return Promise.resolve();
125
+ const fonts = document.fonts;
126
+ const loads = sheet.then(() => Promise.all(CANVAS_FONT_FACES.map((f) => fonts.load(f).catch(() => [])))).then(() => fonts.ready).then(() => undefined);
127
+ const timeout = new Promise<void>((r) => setTimeout(r, timeoutMs));
128
+ return Promise.race([loads, timeout]).then(() => { fontsEpochValue++; fontListeners.forEach((cb) => cb()); });
129
+ }
130
+
131
+ if (typeof document !== 'undefined' && 'fonts' in document) {
132
+ // Late-arriving faces (slow network) invalidate cached text layouts and trigger a redraw.
133
+ document.fonts.addEventListener?.('loadingdone', () => { fontsEpochValue++; fontListeners.forEach((cb) => cb()); });
134
+ }
135
+
136
+ // ── Pixel ratio ───────────────────────────────────────────────────────────────
137
+
138
+ /** Device pixel ratio the canvas renders at (capped: >3 only costs memory). */
139
+ export const currentPixelRatio = (): number =>
140
+ typeof window === 'undefined' ? 1 : Math.min(Math.max(window.devicePixelRatio || 1, 1), 3);
141
+
142
+ /** Calls `cb` whenever devicePixelRatio changes (browser zoom, moving the window between displays). */
143
+ export function watchPixelRatio(cb: (ratio: number) => void): () => void {
144
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
145
+ let mql: MediaQueryList;
146
+ let stopped = false;
147
+ const arm = () => {
148
+ mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
149
+ mql.addEventListener('change', onChange, { once: true });
150
+ };
151
+ const onChange = () => { if (stopped) return; cb(currentPixelRatio()); arm(); };
152
+ arm();
153
+ return () => { stopped = true; mql.removeEventListener('change', onChange); };
154
+ }
@@ -0,0 +1,38 @@
1
+ // ─── useDesign ───────────────────────────────────────────────────────────────
2
+ //
3
+ // Resolves theme + comfort props against the reader's environment (colour
4
+ // scheme, forced colours, reduced motion) and keeps them live.
5
+
6
+ import { useMemo, useSyncExternalStore } from 'react';
7
+ import { resolveDesign, type Design, type DesignProps } from './design';
8
+
9
+ function useMedia(query: string): boolean {
10
+ const subscribe = (cb: () => void) => {
11
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
12
+ const mq = window.matchMedia(query);
13
+ mq.addEventListener('change', cb);
14
+ return () => mq.removeEventListener('change', cb);
15
+ };
16
+ const get = () => (typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches);
17
+ return useSyncExternalStore(subscribe, get, () => false);
18
+ }
19
+
20
+ /** The reader's current preferences. */
21
+ export function useReaderEnv() {
22
+ return {
23
+ prefersDark: useMedia('(prefers-color-scheme: dark)'),
24
+ forcedColors: useMedia('(forced-colors: active)'),
25
+ prefersReducedMotion: useMedia('(prefers-reduced-motion: reduce)'),
26
+ };
27
+ }
28
+
29
+ export function useDesign(props: DesignProps): Design {
30
+ const env = useReaderEnv();
31
+ const { theme, isDark, respectForcedColors } = props;
32
+ const { readableType, largerText, reducedMotion } = props.comfort ?? {};
33
+ return useMemo(
34
+ () => resolveDesign({ theme, isDark, respectForcedColors, comfort: { readableType, largerText, reducedMotion } }, env),
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ [theme, isDark, respectForcedColors, readableType, largerText, reducedMotion, env.prefersDark, env.forcedColors, env.prefersReducedMotion],
37
+ );
38
+ }
@@ -0,0 +1,15 @@
1
+ // GENERATED by scripts/gen-design-system.mjs from design-system/ (tokens/tokens.css, css/spec.css). Do not edit.
2
+ // Change the design system instead; the next `npm run build` regenerates this file.
3
+
4
+ export const SPEC_TOKENS_CSS = "/* Spec tokens for all 8 themes — generated from the Visualli design system (v0.2.0). Do not edit here: changes come in through PRs from the private design system. MIT license, see ../LICENSE. */\n:root, [data-theme=\"light\"] {\n --font-display: \"Atkinson Hyperlegible Next\", system-ui, sans-serif;\n --font-ui: \"Atkinson Hyperlegible Next\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --font-hand: Kalam, \"Atkinson Hyperlegible Next\", cursive;\n --font-note: Caveat, Kalam, cursive;\n --font-mono: \"Atkinson Hyperlegible Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n --canvas: #f6f3ee;\n --surface: #ffffff;\n --surface-sunken: #f0ece5;\n --surface-overlay: #ffffff;\n --line: #e4dfd6;\n --line-strong: #7a7f94;\n --ink: #182140;\n --ink-muted: #4f5671;\n --ink-subtle: #5f647c;\n --action: #182140;\n --on-action: #ffffff;\n --focus: #325e8c;\n --link: #2f5a87;\n --selection: rgba(18, 199, 211, 0.28);\n --node-ink: #182140;\n --edge: #2a3048;\n --edge-label: #4f5671;\n --topic-teal: #87f2f8;\n --topic-teal-ring: #00cdd5;\n --topic-harbor: #bcdbff;\n --topic-harbor-ring: #76b2f5;\n --topic-iris: #e0c8fe;\n --topic-iris-ring: #bd96ea;\n --topic-berry: #ffbdc4;\n --topic-berry-ring: #f47e8f;\n --topic-coral: #ffc7b2;\n --topic-coral-ring: #f38e65;\n --topic-amber: #ffcf9d;\n --topic-amber-ring: #eb9c38;\n --topic-sun: #ffea94;\n --topic-sun-ring: #dfbd04;\n --topic-stone: #e0dacf;\n --topic-stone-ring: #bab09f;\n --space-1: 4px;\n --space-2: 8px;\n --space-3: 12px;\n --space-4: 16px;\n --space-7: 48px;\n --radius-sm: 10px;\n --radius-md: 16px;\n --radius-lg: 24px;\n --radius-pill: 999px;\n --shadow-float: 0 1px 2px rgba(24, 33, 64, 0.06), 0 8px 24px rgba(24, 33, 64, 0.08);\n --shadow-pop: 0 2px 4px rgba(24, 33, 64, 0.08), 0 16px 40px rgba(24, 33, 64, 0.14);\n --node-width: 200px;\n --node-height: 148px;\n --node-stroke: 3px;\n --edge-width: 2.5px;\n --edge-gap: 15px;\n --edge-arc-max: 45px;\n --immersion-alpha: 0.15;\n --zoom-min: 0.3;\n --zoom-max: 5;\n --duration-instant: 100ms;\n --duration-quick: 160ms;\n --duration-base: 240ms;\n --duration-zoom: 500ms;\n --duration-reveal: 420ms;\n --stagger-reveal: 70ms;\n --ease-zoom: cubic-bezier(0.2, 0.8, 0.2, 1);\n --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);\n --ease-bloom: cubic-bezier(0.34, 1.4, 0.64, 1);\n --z-chrome: 10;\n --z-card: 20;\n --z-menu: 40;\n }\n[data-theme=\"dark\"] {\n --canvas: #0f1220;\n --surface: #171b2b;\n --surface-sunken: #0f1220;\n --surface-overlay: #212639;\n --line: #2b3148;\n --line-strong: #6c7392;\n --ink: #eef0f7;\n --ink-muted: #b3b8cc;\n --ink-subtle: #8f95ae;\n --action: #f6f3ee;\n --on-action: #182140;\n --focus: #12c7d3;\n --link: #5fd8e1;\n --selection: rgba(18, 199, 211, 0.32);\n --edge: #c9cddc;\n --edge-label: #b3b8cc;\n --topic-teal-ring: #0fc5cd;\n --topic-harbor-ring: #73aceb;\n --topic-iris-ring: #b691e1;\n --topic-berry-ring: #eb798a;\n --topic-coral-ring: #ea8962;\n --topic-amber-ring: #e29737;\n --topic-sun-ring: #d8b70a;\n --topic-stone-ring: #b4a999;\n --shadow-float: 0 1px 2px rgba(0, 0, 0, 0.40), 0 8px 24px rgba(0, 0, 0, 0.35);\n --shadow-pop: 0 2px 4px rgba(0, 0, 0, 0.50), 0 16px 40px rgba(0, 0, 0, 0.50);\n }\n[data-theme=\"focus-light\"] {\n --canvas: #f4f2ee;\n --edge: #5a6079;\n --edge-label: #5a6079;\n --topic-teal: #bce8ea;\n --topic-teal-ring: #67c6cb;\n --topic-harbor: #c9d9ec;\n --topic-harbor-ring: #8bb2de;\n --topic-iris: #d9cfe8;\n --topic-iris-ring: #b79ed4;\n --topic-berry: #edc6c9;\n --topic-berry-ring: #da9099;\n --topic-coral: #eecec1;\n --topic-coral-ring: #db9b82;\n --topic-amber: #eed4ba;\n --topic-amber-ring: #d6a570;\n --topic-sun: #f4eac1;\n --topic-sun-ring: #d2bf73;\n --topic-stone: #dddad5;\n }\n[data-theme=\"focus-dark\"] {\n --canvas: #12141c;\n --surface: #171b2b;\n --surface-sunken: #0f1220;\n --surface-overlay: #212639;\n --line: #2b3148;\n --line-strong: #6c7392;\n --ink: #eef0f7;\n --ink-muted: #b3b8cc;\n --ink-subtle: #8f95ae;\n --action: #f6f3ee;\n --on-action: #182140;\n --focus: #12c7d3;\n --link: #5fd8e1;\n --selection: rgba(18, 199, 211, 0.32);\n --edge: #a9aec0;\n --edge-label: #b3b8cc;\n --topic-teal: #bce8ea;\n --topic-teal-ring: #71bdc1;\n --topic-harbor: #c9d9ec;\n --topic-harbor-ring: #8cabce;\n --topic-iris: #d9cfe8;\n --topic-iris-ring: #ae9bc6;\n --topic-berry: #edc6c9;\n --topic-berry-ring: #cc8e95;\n --topic-coral: #eecec1;\n --topic-coral-ring: #cd9884;\n --topic-amber: #eed4ba;\n --topic-amber-ring: #c9a176;\n --topic-sun: #f4eac1;\n --topic-sun-ring: #c9b97a;\n --topic-stone: #dddad5;\n --topic-stone-ring: #b4a999;\n }\n[data-theme=\"colorsafe-light\"] {\n --topic-teal: #b0ecbc;\n --topic-teal-ring: #6cc982;\n --topic-harbor: #abdbfe;\n --topic-harbor-ring: #60b4ec;\n --topic-iris: #f6bde2;\n --topic-iris-ring: #dc87c0;\n --topic-berry: #feb79e;\n --topic-berry-ring: #ec7f58;\n --topic-coral: #fece96;\n --topic-coral-ring: #e49d41;\n --topic-amber: #b2e8fa;\n --topic-amber-ring: #5cc4e2;\n --topic-sun: #f6f097;\n --topic-sun-ring: #d0c637;\n --topic-stone: #dbdbdb;\n --topic-stone-ring: #b7afb1;\n }\n[data-theme=\"colorsafe-dark\"] {\n --canvas: #0f1220;\n --surface: #171b2b;\n --surface-sunken: #0f1220;\n --surface-overlay: #212639;\n --line: #2b3148;\n --line-strong: #6c7392;\n --ink: #eef0f7;\n --ink-muted: #b3b8cc;\n --ink-subtle: #8f95ae;\n --action: #f6f3ee;\n --on-action: #182140;\n --focus: #12c7d3;\n --link: #5fd8e1;\n --selection: rgba(18, 199, 211, 0.32);\n --edge: #c9cddc;\n --edge-label: #b3b8cc;\n --topic-teal: #b0ecbc;\n --topic-teal-ring: #69c27e;\n --topic-harbor: #abdbfe;\n --topic-harbor-ring: #5eade2;\n --topic-iris: #f6bde2;\n --topic-iris-ring: #d383b9;\n --topic-berry: #feb79e;\n --topic-berry-ring: #e37a55;\n --topic-coral: #fece96;\n --topic-coral-ring: #db9840;\n --topic-amber: #b2e8fa;\n --topic-amber-ring: #5bbdda;\n --topic-sun: #f6f097;\n --topic-sun-ring: #c9bf37;\n --topic-stone: #dbdbdb;\n --topic-stone-ring: #b1a8aa;\n }\n[data-theme=\"contrast-light\"] {\n --canvas: #ffffff;\n --surface-sunken: #f2f2f2;\n --line: #5c6178;\n --line-strong: #000000;\n --ink: #000000;\n --ink-muted: #1f2433;\n --ink-subtle: #3a3f52;\n --action: #000000;\n --focus: #0b4a8f;\n --link: #0b4a8f;\n --node-ink: #000000;\n --edge: #000000;\n --edge-label: #1f2433;\n --topic-teal-ring: #006b70;\n --topic-harbor-ring: #0e599d;\n --topic-iris-ring: #683b92;\n --topic-berry-ring: #96173a;\n --topic-coral-ring: #923400;\n --topic-amber-ring: #7e4d00;\n --topic-sun-ring: #7a6600;\n --topic-stone-ring: #63594b;\n --shadow-float: none;\n --shadow-pop: none;\n }\n[data-theme=\"contrast-dark\"] {\n --canvas: #000000;\n --surface: #000000;\n --surface-sunken: #0d0d0d;\n --surface-overlay: #0d0d0d;\n --line: #9aa0b8;\n --line-strong: #ffffff;\n --ink: #ffffff;\n --ink-muted: #e6e8f0;\n --ink-subtle: #c8ccda;\n --action: #ffffff;\n --on-action: #000000;\n --focus: #5fe8f0;\n --link: #7ff0f5;\n --selection: rgba(18, 199, 211, 0.32);\n --node-ink: #000000;\n --edge: #ffffff;\n --edge-label: #e6e8f0;\n --topic-teal-ring: #64eff7;\n --topic-harbor-ring: #b0d5fe;\n --topic-iris-ring: #dcbfff;\n --topic-berry-ring: #feb3bb;\n --topic-coral-ring: #ffbea5;\n --topic-amber-ring: #ffc78a;\n --topic-sun-ring: #ffe26d;\n --topic-stone-ring: #ddd3c2;\n --shadow-float: none;\n --shadow-pop: none;\n }\n[data-motion=\"reduced\"] {\n --duration-instant: 120ms;\n --duration-quick: 0ms;\n --duration-base: 0ms;\n --duration-zoom: 0ms;\n --duration-reveal: 0ms;\n --stagger-reveal: 0ms;\n }\n@media (prefers-reduced-motion: reduce) {\n :root:not([data-motion=\"full\"]) {\n --duration-instant: 120ms;\n --duration-quick: 0ms;\n --duration-base: 0ms;\n --duration-zoom: 0ms;\n --duration-reveal: 0ms;\n --stagger-reveal: 0ms;\n }\n}\n\n/* Type styles */\n.heading { font-family: var(--font-ui); font-size: 17px; line-height: 24px; font-weight: 700; }\n.body { font-family: var(--font-ui); font-size: 15px; line-height: 22px; font-weight: 400; }\n.body-sm { font-family: var(--font-ui); font-size: 13px; line-height: 18px; font-weight: 400; }\n.label { font-family: var(--font-ui); font-size: 13px; line-height: 16px; font-weight: 600; letter-spacing: 0.01em; }\n.caption { font-family: var(--font-ui); font-size: 12px; line-height: 16px; font-weight: 400; }\n.node-root { font-family: var(--font-hand); font-size: 30px; line-height: 34px; font-weight: 700; }\n.node-label { font-family: var(--font-hand); font-size: 22px; line-height: 26px; font-weight: 700; }\n.edge-label { font-family: var(--font-note); font-size: 18px; line-height: 20px; font-weight: 500; }\n.annotation { font-family: var(--font-note); font-size: 24px; line-height: 26px; font-weight: 600; }\n";
5
+ export const SPEC_COMPONENT_CSS = "/* Styles for the spec components (map, nodes, connectors, peek, terms, depth trail, controls) — generated from the Visualli design system (v0.2.0). Do not edit here: changes come in through PRs from the private design system. MIT license, see ../LICENSE. */\n:root { --vi-fs-body: 15px; --vi-fs-sm: 13px; --vi-fs-cap: 12px; --vi-lh-body: 22px; --vi-label-scale: 1; color-scheme: light; }\n[data-theme$=\"dark\"] { color-scheme: dark; }\n[data-scale=\"lg\"] { --vi-fs-body: 17px; --vi-fs-sm: 15px; --vi-fs-cap: 14px; --vi-lh-body: 26px; --vi-label-scale: 1.15; }\n[data-type=\"readable\"] { --font-hand: \"Atkinson Hyperlegible Next\", system-ui, sans-serif; --font-note: \"Atkinson Hyperlegible Next\", system-ui, sans-serif; --font-display: \"Atkinson Hyperlegible Next\", system-ui, sans-serif; letter-spacing: 0.01em; }\n[data-type=\"readable\"] .vi-node__label, [data-type=\"readable\"] .vi-edge__label, [data-type=\"readable\"] .vi-map__group-label { font-family: var(--font-ui) !important; font-weight: 700; letter-spacing: 0.02em; font-style: normal; }\n\n.vi-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.vi-icon { flex: none; display: block; }\n.vi-map :where(button, a, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }\n.vi-iconbtn { display: inline-grid; place-items: center; border-radius: var(--radius-sm); border: 1.5px solid transparent; background: transparent; color: var(--ink); cursor: pointer; transition: background var(--duration-instant); flex: none; }\n.vi-iconbtn--sm { width: 32px; height: 32px; }\n.vi-iconbtn--md { width: 40px; height: 40px; }\n.vi-iconbtn--lg { width: 48px; height: 48px; border-radius: var(--radius-md); }\n.vi-iconbtn:hover { background: var(--surface-sunken); }\n.vi-iconbtn--surface { background: var(--surface); border-color: var(--line); }\n.vi-iconbtn--surface:hover { border-color: var(--line-strong); }\n.vi-iconbtn--primary { background: var(--action); color: var(--on-action); border-radius: var(--radius-pill); }\n.vi-iconbtn.is-active { background: var(--action); color: var(--on-action); }\n[data-theme^=\"contrast\"] .vi-ctrls { border: 2px solid var(--line-strong); }\n.vi-node { position: relative; display: inline-grid; place-items: center; border: 0; padding: 0; background: none; color: var(--node-ink); cursor: default; transition: transform var(--duration-base) var(--ease-bloom), opacity var(--duration-base); -webkit-tap-highlight-color: transparent; }\nbutton.vi-node { cursor: pointer; }\n.vi-node__svg { position: absolute; inset: 0; overflow: visible; }\n.vi-node__body { filter: drop-shadow(var(--vi-node-shadow, 0 6px 14px rgba(24, 33, 64, .10))); }\n[data-theme$=\"dark\"] .vi-node__body { --vi-node-shadow: 0 0 22px rgba(255, 255, 255, .10); }\n[data-theme^=\"contrast\"] .vi-node__body { filter: none; }\n.vi-node__rings { transform-origin: center; transition: transform var(--duration-base) var(--ease-standard); }\n@media (hover: hover) {\n button.vi-node:hover .vi-node__rings { transform: rotate(var(--vi-ring-turn, -2deg)) scale(var(--vi-ring-grow, 1.015)); }\n button.vi-node:hover { transform: translateY(calc(var(--vi-lift, 2px) * -1)); }\n}\n.vi-node { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: manipulation; }\n.vi-node.is-selected { transform: translateY(-2px); }\n.vi-node.is-selected .vi-node__rings { transform: rotate(var(--vi-ring-turn, -2deg)) scale(var(--vi-ring-grow, 1.015)); }\n.vi-node.is-selected .vi-node__body { stroke-width: 4; }\n.vi-node:focus-visible { outline: none; }\n.vi-node:focus-visible .vi-node__body { stroke: var(--focus); stroke-width: 4; }\n.vi-node.is-selected .vi-node__body { stroke: var(--ink); }\n.vi-node__label { position: relative; text-align: center; color: var(--node-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: calc(var(--vi-nfs, 22px) * var(--vi-label-scale) * var(--vi-ninv, 1)); line-height: 1.15; padding-top: 2px; }\n.vi-node__label.node-root { --vi-nfs: 30px; }\n.vi-node--mini .vi-node__label { --vi-nfs: 15px; }\n.vi-node.is-dimmed { opacity: 1; }\n[data-theme^=\"focus\"] .vi-node.is-dimmed { opacity: .4; }\n.vi-map.is-touch .vi-node.is-dimmed { opacity: .6; }\n[data-theme^=\"focus\"] .vi-map.is-touch .vi-node.is-dimmed { opacity: .4; }\n.vi-edge__line { fill: none; stroke: var(--edge); stroke-width: 2.5; stroke-linecap: round; }\n.vi-edge.is-dashed .vi-edge__line { stroke-dasharray: 7 7; }\n.vi-edge__arrow { fill: none; stroke: var(--edge); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }\n.vi-edge__label { font: 500 calc(18px * var(--vi-inv, 1)) / 1 var(--font-note); fill: var(--edge-label); paint-order: stroke; stroke: var(--canvas); stroke-width: 5px; stroke-linejoin: round; letter-spacing: .01em; }\n[data-type=\"readable\"] .vi-edge__label { font-size: calc(14px * var(--vi-inv, 1)); font-weight: 600; }\n.vi-fact { position: relative; width: 300px; box-sizing: border-box; padding: var(--space-4) var(--space-4) var(--space-3); background: var(--surface-overlay); color: var(--ink); border: 2px solid var(--vi-fact-ring); border-radius: 14px 18px 12px 16px / 16px 12px 18px 14px; font: 400 var(--vi-fs-sm)/20px var(--font-ui); z-index: var(--z-card); animation: vi-pop var(--duration-quick) var(--ease-standard); }\n.vi-fact { box-shadow: 5px 6px 0 0 var(--vi-fact-fill), 5px 6px 0 2px var(--vi-fact-ring); }\n[data-theme^=\"contrast\"] .vi-fact { border-color: var(--ink); }\n[data-theme^=\"contrast\"] .vi-fact { box-shadow: none; }\n.vi-fact__title { font: 700 22px/26px var(--font-hand); margin-bottom: var(--space-1); }\n.vi-fact__body { color: var(--ink); }\n.vi-fact__foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px dashed var(--line); }\n.vi-fact__acts { display: inline-flex; gap: var(--space-3); align-items: center; flex: none; }\n.vi-fact__link { color: var(--link); font: 600 var(--vi-fs-cap)/16px var(--font-ui); }\n.vi-fact__step { display: inline-flex; align-items: center; gap: 2px; border: 0; background: var(--action); color: var(--on-action); font: 600 var(--vi-fs-cap)/16px var(--font-ui); padding: 5px 8px 5px 10px; border-radius: var(--radius-pill); cursor: pointer; }\n.vi-trail ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; position: relative; }\n.vi-trail li { position: relative; }\n.vi-trail li + li::before { content: \"\"; position: absolute; left: 13px; top: -8px; height: 10px; border-left: 1.5px dashed var(--line-strong); }\n.vi-trail button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 4px 8px 4px 5px; border-radius: var(--radius-sm); color: var(--ink-muted); font: 400 var(--vi-fs-cap)/16px var(--font-ui); cursor: pointer; }\n.vi-trail button span { display: inline-flex; align-items: center; gap: 4px; }\n.vi-trail button:hover:not(:disabled) { background: var(--surface-sunken); color: var(--ink); }\n.vi-trail li.is-current button { color: var(--ink); font-weight: 700; font-size: var(--vi-fs-sm); cursor: default; background: var(--surface); box-shadow: var(--shadow-float); }\n.vi-trail li.is-current button:disabled { opacity: 1; }\n.vi-ctrls { display: inline-flex; align-items: center; gap: 2px; padding: 4px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-float); }\n.vi-ctrls__pct { min-width: 44px; text-align: center; font: 600 var(--vi-fs-cap)/16px var(--font-ui); color: var(--ink-muted); font-variant-numeric: tabular-nums; }\n.vi-ctrls__div { width: 1px; height: 20px; background: var(--line); margin: 0 4px; }\n.vi-map { position: relative; overflow: hidden; background: var(--canvas); border-radius: inherit; isolation: isolate; }\n.vi-map__immersion { position: absolute; inset: 0; opacity: var(--immersion-alpha); transition: background var(--duration-base); z-index: -1; }\n.vi-map__world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }\n.vi-map__edges { position: absolute; overflow: visible; pointer-events: none; }\n.vi-map__group rect { fill: none; stroke: var(--line-strong); stroke-width: 1.5; stroke-dasharray: 10 6; }\n.vi-map__group-label { position: absolute; transform: translate(-50%, -50%); z-index: 2; max-width: 420px; box-sizing: border-box; padding: 6px 22px 8px; background: var(--topic-stone); border: 1.5px solid var(--topic-stone-ring); border-radius: var(--radius-pill); font: 700 22px/1.1 var(--font-note); color: var(--node-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; font-size: calc(26px * var(--vi-label-scale) * var(--vi-ninv, 1)); }\n[data-theme^=\"contrast\"] .vi-map__group-label { border: 2px solid var(--line-strong); }\n.vi-map__slot { position: absolute; transform: translate(-50%, -50%); }\n.vi-map.is-reveal .vi-map__slot { animation: vi-bloom var(--duration-reveal) var(--ease-bloom) backwards; }\n.vi-map.is-reveal .vi-edge { animation: vi-fade var(--duration-base) var(--ease-standard) backwards; }\n.vi-map.is-reveal .vi-edge__line { stroke-dasharray: 1; stroke-dashoffset: 1; animation: vi-line var(--vi-line-draw, .5s) var(--ease-standard) forwards; animation-delay: inherit; }\n.vi-map.is-reveal .vi-edge.is-dashed .vi-edge__line { stroke-dasharray: 7 7; stroke-dashoffset: 0; animation: vi-fade var(--vi-line-draw, .5s) var(--ease-standard) backwards; animation-delay: inherit; }\n.vi-map:not(.is-reveal) .vi-edge__line { stroke-dasharray: none; }\n.vi-map:not(.is-reveal) .vi-edge.is-dashed .vi-edge__line { stroke-dasharray: 7 7; }\n.vi-map__lens { position: absolute; inset: 0; transform-origin: var(--vi-ox) var(--vi-oy); }\n.vi-map.is-diving .vi-map__lens { transform: scale(var(--vi-dive-scale, 3.2)); opacity: 0; transition: transform var(--duration-zoom) var(--ease-zoom), opacity var(--vi-dive-fade, 160ms) var(--vi-dive-fade-delay, 150ms); }\n.vi-map.is-surfacing .vi-map__lens { transform: scale(var(--vi-surface-scale, .6)); opacity: 0; transform-origin: 50% 50%; transition: transform var(--vi-surface-dur, 220ms) var(--ease-zoom), opacity var(--vi-surface-fade, 200ms); }\n.vi-map.is-reveal .vi-map__lens { animation: vi-settle var(--vi-settle-dur, 260ms) var(--ease-standard); }\n[data-motion=\"reduced\"] .vi-map__lens { animation: none !important; transform: none !important; }\n.vi-map__fact { position: absolute; }\n.vi-map.is-idle .vi-map__world { transition: transform var(--duration-base) var(--ease-standard); }\n.vi-map__hint { position: absolute; left: var(--space-4); right: var(--space-4); margin: 0; text-align: center; color: var(--ink-muted); pointer-events: none; z-index: var(--z-chrome); animation: vi-fade var(--duration-base) var(--ease-standard); }\n.vi-fact__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-2); }\n.vi-fact__back { display: inline-flex; align-items: center; gap: 2px; border: 0; background: none; padding: 2px 0; margin-bottom: var(--space-1); color: var(--link); font: 600 var(--vi-fs-cap)/16px var(--font-ui); cursor: pointer; }\n.vi-term { border: 0; background: none; padding: 0; font: inherit; color: var(--link); text-decoration: underline; text-decoration-style: dotted; text-decoration-thickness: 1.5px; text-underline-offset: 3px; cursor: pointer; }\n.vi-fact--sheet { position: absolute; left: 0; right: 0; bottom: 0; width: auto; max-width: 560px; margin: 0 auto; max-height: var(--vi-sheet-h, 300px); overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--space-4) calc(var(--space-4) + env(safe-area-inset-bottom, 0px)); border: 0; border-top: 2px solid var(--vi-fact-ring); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: 0 -6px 0 0 var(--vi-fact-fill), var(--shadow-pop); font-size: var(--vi-fs-body); line-height: var(--vi-lh-body); animation: vi-sheet-in var(--duration-base) var(--ease-standard); transition: transform var(--duration-quick) var(--ease-standard), max-height var(--duration-base) var(--ease-standard); }\n.vi-fact--sheet.is-expanded { max-height: calc(100% - var(--space-7)); }\n.vi-fact__grip { position: sticky; top: 0; display: flex; justify-content: center; margin: 0 calc(var(--space-4) * -1); background: var(--surface-overlay); touch-action: none; cursor: grab; z-index: 1; }\n.vi-fact__handle { position: relative; width: 64px; height: 24px; border: 0; padding: 0; background: none; cursor: pointer; }\n.vi-fact__handle::after { content: \"\"; position: absolute; left: 12px; right: 12px; top: 10px; height: 5px; border-radius: var(--radius-pill); background: var(--line-strong); }\n.vi-fact--sheet .vi-fact__title { font-size: calc(24px * var(--vi-label-scale)); line-height: 1.15; padding-top: var(--space-1); }\n.vi-fact--sheet .vi-fact__close { margin: 0 calc(var(--space-2) * -1) 0 0; }\n.vi-fact--sheet .vi-fact__step { min-height: 44px; padding: 0 var(--space-3) 0 var(--space-4); font-size: var(--vi-fs-sm); }\n.vi-fact--sheet .vi-fact__link { font-size: var(--vi-fs-sm); min-height: 44px; display: inline-flex; align-items: center; }\n[data-motion=\"reduced\"] .vi-fact--sheet { animation: vi-fade 120ms; }\n@media (prefers-reduced-motion: reduce) {\n .vi-fact--sheet { animation: vi-fade 120ms; }\n .vi-map.is-idle .vi-map__world { transition: none; }\n}\n.vi-map__trail { position: absolute; top: var(--space-4); left: var(--space-4); z-index: var(--z-chrome); }\n.vi-map__ctrls { position: absolute; top: var(--space-4); right: var(--space-4); z-index: var(--z-chrome); }\n[data-reveal=\"instant\"] .vi-map.is-reveal .vi-map__slot, [data-motion=\"reduced\"] .vi-map.is-reveal .vi-map__slot { animation: vi-fade var(--vi-reduced-fade, 120ms) backwards; animation-delay: 0ms !important; }\n[data-reveal=\"instant\"] .vi-map.is-reveal .vi-edge, [data-reveal=\"instant\"] .vi-map.is-reveal .vi-edge__line, [data-motion=\"reduced\"] .vi-map.is-reveal .vi-edge, [data-motion=\"reduced\"] .vi-map.is-reveal .vi-edge__line { animation: none; stroke-dashoffset: 0; }\n@media (prefers-reduced-motion: reduce) {\n .vi-map.is-reveal .vi-map__slot { animation: vi-fade var(--vi-reduced-fade, 120ms) backwards; animation-delay: 0ms !important; }\n .vi-map.is-reveal .vi-edge, .vi-map.is-reveal .vi-edge__line { animation: none; stroke-dashoffset: 0; }\n}\n[data-motion=\"reduced\"] *, [data-motion=\"reduced\"] *::before, [data-motion=\"reduced\"] *::after { transition-duration: 1ms !important; }\n.vi-fact__acts { flex-wrap: wrap; justify-content: flex-end; }\n.vi-fact__link { display: inline-flex; align-items: center; gap: 2px; }\n.vi-anchor { position: relative; display: inline; }\n.vi-anchor.is-open .vi-term { background: var(--selection); border-radius: 3px; }\n.vi-anchor__pop { position: absolute; left: -12px; top: calc(100% + 6px); z-index: var(--z-menu); display: block; width: max-content; }\n.vi-anchor-card { position: relative; display: block; width: min(300px, calc(100vw - 32px)); box-sizing: border-box; padding: var(--space-3) var(--space-4) var(--space-3); background: var(--surface-overlay); color: var(--ink); text-align: left; white-space: normal; font: 400 var(--vi-fs-sm)/20px var(--font-ui); border: 2px solid var(--vi-fact-ring); border-radius: 12px 16px 10px 14px / 14px 10px 16px 12px; box-shadow: 4px 5px 0 0 var(--vi-fact-fill), 4px 5px 0 2px var(--vi-fact-ring), var(--shadow-pop); animation: vi-pop var(--duration-quick) var(--ease-standard); }\n.vi-anchor-card.is-in-sheet { width: auto; border: 0; box-shadow: none; padding: 0; animation: none; background: transparent; }\n[data-theme^=\"contrast\"] .vi-anchor-card { border-color: var(--ink); box-shadow: none; }\n.vi-anchor-card__eyebrow { font: 400 11px/14px var(--font-display); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-muted); }\n.vi-anchor-card__word { font: 700 20px/24px var(--font-hand); color: var(--ink); margin: 2px 0 var(--space-1); }\n.vi-fact--sheet .vi-anchor-card__word { font-size: calc(24px * var(--vi-label-scale)); line-height: 1.15; }\n.vi-anchor-card__desc { margin: 0; color: var(--ink); }\n.vi-anchor-card__acts { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px dashed var(--line); }\n.vi-fact--sheet .vi-anchor-card__acts .vi-fact__link { min-height: 44px; }\n[data-type=\"readable\"] .vi-anchor-card__word { font-family: var(--font-ui); }\n[data-motion=\"reduced\"] .vi-anchor-card { animation: vi-fade 120ms; }\n@media (prefers-reduced-motion: reduce) {\n .vi-anchor-card { animation: vi-fade 120ms; }\n}\n.vi-peek .vi-fact__foot { flex-wrap: wrap; row-gap: var(--space-2); }\n.vi-peek .vi-fact__acts { margin-left: auto; }\n.vi-map.has-highlight .vi-node.is-dimmed { opacity: .35; transition: opacity var(--duration-quick) var(--ease-standard); }\n.vi-map.has-highlight .vi-node.is-selected .vi-node__body { stroke: var(--ink); }\n.vi-map { container-type: inline-size; }\n.vi-edge__hit { fill: none; stroke: transparent; stroke-width: 18; pointer-events: stroke; cursor: pointer; }\n.vi-edge__hit:hover + .vi-edge__line { stroke-width: 3.5; }\n.vi-edge.is-selected .vi-edge__line, .vi-edge.is-selected .vi-edge__arrow { stroke: var(--focus); stroke-width: 3.5; }\n.vi-map__group-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: pointer; }\n.vi-map__group.is-selected rect:not(.vi-map__group-hit) { stroke: var(--focus); stroke-width: 2.5; stroke-dasharray: none; }\n.vi-map__group-label.is-selected { border-color: var(--focus); color: var(--focus); box-shadow: 0 0 0 1px var(--focus); }\n.vi-map { touch-action: pan-x pan-y; }\n.vi-map.is-app { touch-action: none; }\n.vi-map.is-pinching .vi-map__world { transition: none; }\n.vi-map.is-instant.is-reveal .vi-map__slot { animation: vi-fade var(--vi-reduced-fade, 120ms) backwards; animation-delay: 0ms !important; }\n.vi-map.is-instant.is-reveal .vi-edge, .vi-map.is-instant.is-reveal .vi-edge__line { animation: none; stroke-dashoffset: 0; }\n.vi-map.is-instant .vi-node, .vi-map.is-instant .vi-node__rings { transition: none; }\n.vi-map.is-detail-body .vi-node:not(.is-selected):not(:focus-visible):not(:hover) :is(.vi-node__rings, .vi-node__label), .vi-map.is-detail-speck .vi-node:not(.is-selected):not(:focus-visible):not(:hover) :is(.vi-node__rings, .vi-node__label) { display: none; }\n.vi-map.is-detail-body .vi-node:not(.is-selected):not(:focus-visible):not(:hover) .vi-node__body, .vi-map.is-detail-speck .vi-node:not(.is-selected):not(:focus-visible):not(:hover) .vi-node__body, .vi-map.no-shadows .vi-node__body { filter: none; }\n@keyframes vi-pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } }\n@keyframes vi-fade { from { opacity: 0; } }\n@keyframes vi-bloom { from { opacity: 0; transform: translate(calc(-50% + var(--vi-dx)), calc(-50% + var(--vi-dy))) scale(var(--vi-bloom-from, .55)); } }\n@keyframes vi-line { to { stroke-dashoffset: 0; } }\n@keyframes vi-settle { from { transform: scale(var(--vi-settle-from, .94)); } }\n@keyframes vi-sheet-in { from { transform: translateY(100%); } }\n";
6
+ export const SPEC_FONTS_URL = "https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800&family=Atkinson+Hyperlegible+Mono:wght@400;600&family=Kalam:wght@400;700&display=swap";
7
+ export const SPEC_DESIGN_SYSTEM_VERSION = "0.2.0";
8
+ /** Font files shipped with the package (fonts/ directory), from design-system/fonts/fonts.css. */
9
+ export const SPEC_BUNDLED_FONTS = [
10
+ {
11
+ "family": "Caveat",
12
+ "file": "Caveat-Variable.ttf",
13
+ "weight": "400 700"
14
+ }
15
+ ] as const;
@@ -1,7 +1,5 @@
1
1
  export { useKonvaRenderer } from './useKonvaRenderer';
2
2
  export type { UseKonvaRendererOptions } from './useKonvaRenderer';
3
3
 
4
- export { useKonvaLayerTransition } from './useKonvaLayerTransition';
5
- export type { LayerTransitionHandlers } from './useKonvaLayerTransition';
6
4
 
7
5
  export { useViewportNodes } from './useViewportNodes';
@@ -12,7 +12,6 @@
12
12
 
13
13
  import { useEffect, useCallback, useRef, useState } from 'react';
14
14
  import type Konva from 'konva';
15
- import { ZOOM_MIN, ZOOM_MAX } from '@visualli/core';
16
15
  import { useViewportStore } from '../stores/useViewportStore';
17
16
  import { useRenderConfigStore } from '../stores/stores';
18
17
 
@@ -27,19 +26,18 @@ export interface UseKonvaRendererReturn {
27
26
  handleMouseDown:(e: Konva.KonvaEventObject<MouseEvent>) => void;
28
27
  handleMouseMove:(e: Konva.KonvaEventObject<MouseEvent>) => void;
29
28
  handleMouseUp: (e: Konva.KonvaEventObject<MouseEvent>) => void;
29
+ /** End a one-finger / mouse pan now (e.g. a second finger turns it into a pinch), keeping how far it moved. */
30
+ cancelPan: () => void;
30
31
  canvasWidth: number;
31
32
  canvasHeight: number;
32
33
  }
33
34
 
34
35
  export function useKonvaRenderer({
35
36
  containerRef,
36
- stageRef,
37
37
  enabled = true,
38
38
  }: UseKonvaRendererOptions): UseKonvaRendererReturn {
39
39
  const pan = useViewportStore(s => s.pan);
40
40
  const zoom = useViewportStore(s => s.zoom);
41
- const setZoom = useViewportStore(s => s.setZoom);
42
- const setCenter = useViewportStore(s => s.setCenter);
43
41
  const updateCanvasSize = useViewportStore(s => s.updateCanvasSize);
44
42
  const autoAdjust = useRenderConfigStore(s => s.autoAdjustQuality);
45
43
 
@@ -114,7 +112,7 @@ export function useKonvaRenderer({
114
112
  }
115
113
 
116
114
  return () => {
117
- ro ? ro.disconnect() : window.removeEventListener('resize', updateSize);
115
+ if (ro) ro.disconnect(); else window.removeEventListener('resize', updateSize);
118
116
  if (rafId) cancelAnimationFrame(rafId);
119
117
  };
120
118
  }, [enabled, containerRef, updateCanvasSize]);
@@ -138,19 +136,11 @@ export function useKonvaRenderer({
138
136
  isPanningRef.current = true;
139
137
  lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
140
138
  panAccumRef.current = { x: 0, y: 0 };
141
- const stage = e.target.getStage();
142
- if (stage) stage.container().style.cursor = 'grabbing';
143
139
  }
144
140
  }, []);
145
141
 
146
142
  const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>): void => {
147
- if (!isPanningRef.current) {
148
- // Update cursor on empty canvas
149
- const isOverEmpty = e.target === e.target.getStage();
150
- const stage = e.target.getStage();
151
- if (stage) stage.container().style.cursor = isOverEmpty ? 'grab' : 'default';
152
- return;
153
- }
143
+ if (!isPanningRef.current) return;
154
144
  e.evt.preventDefault();
155
145
  const deltaX = e.evt.clientX - lastPanPointRef.current.x;
156
146
  const deltaY = e.evt.clientY - lastPanPointRef.current.y;
@@ -168,7 +158,7 @@ export function useKonvaRenderer({
168
158
  lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
169
159
  }, []); // No Zustand dep — pan flushed on mouseUp
170
160
 
171
- const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>): void => {
161
+ const handleMouseUp = useCallback((): void => {
172
162
  if (isPanningRef.current) {
173
163
  isPanningRef.current = false;
174
164
  const { x, y } = panAccumRef.current;
@@ -176,10 +166,15 @@ export function useKonvaRenderer({
176
166
  pan(x, y);
177
167
  panAccumRef.current = { x: 0, y: 0 };
178
168
  }
179
- const stage = e.target.getStage();
180
- if (stage) stage.container().style.cursor = 'grab';
181
169
  }
182
170
  }, [pan]);
183
171
 
184
- return { handleWheel, handleMouseDown, handleMouseMove, handleMouseUp, canvasWidth, canvasHeight };
172
+ const cancelPan = useCallback((): void => {
173
+ if (!isPanningRef.current) return;
174
+ isPanningRef.current = false;
175
+ const { x, y } = panAccumRef.current;
176
+ if (x !== 0 || y !== 0) { pan(x, y); panAccumRef.current = { x: 0, y: 0 }; }
177
+ }, [pan]);
178
+
179
+ return { handleWheel, handleMouseDown, handleMouseMove, handleMouseUp, cancelPan, canvasWidth, canvasHeight };
185
180
  }
@@ -0,0 +1,159 @@
1
+ // ─── useLayerChoreography ─────────────────────────────────────────────────────
2
+ //
3
+ // Step inside / back out, as the design system's motion.ts describes them:
4
+ //
5
+ // step inside the current layer zooms toward the idea (scale 3.2, ease-zoom, 500ms)
6
+ // and fades (160ms after 150ms); the layers swap at 320ms.
7
+ // back out the current layer shrinks toward the centre (scale .6, 220ms) and
8
+ // fades (200ms); the layers swap at 220ms.
9
+ // arrive the new layer's ideas bloom in and its connectors draw (RevealClock),
10
+ // while the layer as a whole settles from scale .94 (260ms).
11
+ // reduced motion reduced (or instant reveal): no zoom, the layers swap at once and
12
+ // the new one fades in (120ms).
13
+ //
14
+ // The zoom/fade/settle run on the canvas wrapper as compositor animations (Web
15
+ // Animations API), so the Konva canvas is not redrawn while they play.
16
+ // All numbers and easings come from core's MOTION (design-system/geometry/motion.ts).
17
+
18
+ import { useCallback, useEffect, useRef } from 'react';
19
+ import type Konva from 'konva';
20
+ import { MOTION, cssEase, swapDelay, type FlatNode } from '@visualli/core';
21
+ import { useViewportStore } from '../stores/useViewportStore';
22
+ /** A viewport to return to: centre and zoom. */
23
+ export interface AnimatorViewport { centerX: number; centerY: number; zoomLevel: number }
24
+ import { calculateFitView } from '../utils/layerNavigation';
25
+
26
+ export interface LayerChoreographyOptions {
27
+ stageRef: React.RefObject<Konva.Stage | null>;
28
+ /** The element around the Konva stage; the zoom, fade and settle play on it. */
29
+ canvasWrapperRef: React.RefObject<HTMLDivElement | null>;
30
+ reducedMotion: boolean;
31
+ /** Switch the displayed layer to a child layer (the layers swap here). */
32
+ onSwapLayer: (childLayerId: string) => void;
33
+ /** Switch back to the parent layer (the layers swap here). */
34
+ onSwapBack: () => void;
35
+ /** Called once the new layer has settled. */
36
+ onComplete: () => void;
37
+ /** 'start' before anything moves, 'end' after the new layer has settled (pause hit-testing, GPU hints). */
38
+ onLifecycle?: (phase: 'start' | 'end') => void;
39
+ }
40
+
41
+ export interface LayerChoreography {
42
+ /** Step inside `targetNode` into the child layer. */
43
+ stepInside: (targetNode: FlatNode, childLayerId: string, childNodes: ReadonlyArray<FlatNode>) => void;
44
+ /** Back out to a layer whose fit viewport is `parent`. */
45
+ backOut: (parent: AnimatorViewport) => void;
46
+ /** The new layer is committed and drawing at its first frame: lift the veil and settle. Call after the swap's React commit. */
47
+ arrive: () => void;
48
+ isTransitioning: () => boolean;
49
+ }
50
+
51
+ export function useLayerChoreography(o: LayerChoreographyOptions): LayerChoreography {
52
+ const { stageRef, canvasWrapperRef } = o;
53
+ const opts = useRef(o);
54
+ opts.current = o;
55
+
56
+ const busy = useRef(false);
57
+ const waiting = useRef(false); // swapped, waiting for the new layer's commit
58
+ const anims = useRef<Animation[]>([]);
59
+ const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
60
+ const after = useRef<number[]>([]);
61
+
62
+ const clear = useCallback(() => {
63
+ timers.current.forEach(clearTimeout); timers.current = [];
64
+ after.current.forEach(cancelAnimationFrame); after.current = [];
65
+ anims.current.forEach((a) => a.cancel()); anims.current = [];
66
+ }, []);
67
+
68
+ const applyViewport = useCallback((vp: AnimatorViewport) => {
69
+ const stage = stageRef.current;
70
+ if (stage) {
71
+ stage.x(stage.width() / 2 - vp.centerX * vp.zoomLevel);
72
+ stage.y(stage.height() / 2 - vp.centerY * vp.zoomLevel);
73
+ stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
74
+ stage.getLayers().forEach((l) => l.drawScene());
75
+ }
76
+ const s = useViewportStore.getState();
77
+ s.setCenter(vp.centerX, vp.centerY);
78
+ s.setZoom(vp.zoomLevel);
79
+ }, [stageRef]);
80
+
81
+ const lifecycle = useCallback((phase: 'start' | 'end') => {
82
+ const stage = stageRef.current;
83
+ stage?.getLayers().forEach((l) => { l.listening(phase === 'end'); });
84
+ if (phase === 'end') stage?.batchDraw();
85
+ opts.current.onLifecycle?.(phase);
86
+ }, [stageRef]);
87
+
88
+ const leave = useCallback((wrap: HTMLElement | null, dir: 'in' | 'out', origin: string, vp: AnimatorViewport, swap: () => void) => {
89
+ busy.current = true;
90
+ waiting.current = false;
91
+ lifecycle('start');
92
+ const reduced = opts.current.reducedMotion;
93
+ if (wrap && !reduced && typeof wrap.animate === 'function') {
94
+ const m = MOTION;
95
+ wrap.style.transformOrigin = origin;
96
+ wrap.style.willChange = 'transform, opacity';
97
+ if (dir === 'in') {
98
+ anims.current.push(
99
+ wrap.animate([{ transform: 'scale(1)' }, { transform: `scale(${m.dive.scale})` }], { duration: m.dive.duration, easing: cssEase(m.dive.ease), fill: 'forwards' }),
100
+ wrap.animate([{ opacity: 1 }, { opacity: 0 }], { delay: m.dive.fadeDelay, duration: m.dive.fade, easing: 'ease', fill: 'both' }),
101
+ );
102
+ } else {
103
+ anims.current.push(
104
+ wrap.animate([{ transform: 'scale(1)' }, { transform: `scale(${m.surface.scale})` }], { duration: m.surface.duration, easing: cssEase(m.surface.ease), fill: 'forwards' }),
105
+ wrap.animate([{ opacity: 1 }, { opacity: 0 }], { duration: m.surface.fade, easing: 'ease', fill: 'both' }),
106
+ );
107
+ }
108
+ }
109
+ timers.current.push(setTimeout(() => {
110
+ waiting.current = true;
111
+ swap();
112
+ applyViewport(vp);
113
+ // Safety net: if the host never reports the commit, arrive anyway.
114
+ timers.current.push(setTimeout(() => arriveRef.current(), 500));
115
+ }, swapDelay(dir, reduced)));
116
+ }, [applyViewport, lifecycle]);
117
+
118
+ const arriveRef = useRef<() => void>(() => {});
119
+ const arrive = useCallback(() => {
120
+ if (!waiting.current) return;
121
+ waiting.current = false;
122
+ const wrap = canvasWrapperRef.current;
123
+ const reduced = opts.current.reducedMotion;
124
+ // Drop the zoomed-and-faded veil (the new layer is drawing its first frame, ideas not yet bloomed) and settle.
125
+ anims.current.forEach((a) => a.cancel()); anims.current = [];
126
+ let settle = reduced ? MOTION.reduced.fade : MOTION.settle.duration;
127
+ if (wrap && !reduced && typeof wrap.animate === 'function') {
128
+ const a = wrap.animate([{ transform: `scale(${MOTION.settle.fromScale})` }, { transform: 'none' }], { duration: MOTION.settle.duration, easing: cssEase(MOTION.settle.ease) });
129
+ anims.current.push(a);
130
+ settle = MOTION.settle.duration;
131
+ }
132
+ timers.current.push(setTimeout(() => {
133
+ anims.current.forEach((a) => a.cancel()); anims.current = [];
134
+ if (wrap) { wrap.style.willChange = 'auto'; wrap.style.transformOrigin = ''; }
135
+ busy.current = false;
136
+ opts.current.onComplete();
137
+ lifecycle('end');
138
+ }, settle));
139
+ }, [canvasWrapperRef, lifecycle]);
140
+ arriveRef.current = arrive;
141
+
142
+ const stepInside = useCallback((targetNode: FlatNode, childLayerId: string, childNodes: ReadonlyArray<FlatNode>) => {
143
+ if (busy.current) return;
144
+ const stage = stageRef.current;
145
+ const zoom = stage?.scaleX() ?? 1;
146
+ const origin = stage ? `${targetNode.x * zoom + stage.x()}px ${targetNode.y * zoom + stage.y()}px` : '50% 50%';
147
+ const fit = calculateFitView(childNodes as FlatNode[], stage?.width() ?? 800, stage?.height() ?? 600);
148
+ leave(canvasWrapperRef.current, 'in', origin, { centerX: fit.centerX, centerY: fit.centerY, zoomLevel: fit.zoomLevel }, () => opts.current.onSwapLayer(childLayerId));
149
+ }, [stageRef, canvasWrapperRef, leave]);
150
+
151
+ const backOut = useCallback((parent: AnimatorViewport) => {
152
+ if (busy.current) return;
153
+ leave(canvasWrapperRef.current, 'out', '50% 50%', parent, () => opts.current.onSwapBack());
154
+ }, [canvasWrapperRef, leave]);
155
+
156
+ const isTransitioning = useCallback(() => busy.current, []);
157
+ useEffect(() => () => { clear(); busy.current = false; }, [clear]);
158
+ return { stepInside, backOut, arrive, isTransitioning };
159
+ }
@@ -54,6 +54,5 @@ export function useViewportNodes(
54
54
  spatialIndex: indexRef.current,
55
55
  spatialIndexThreshold: SPATIAL_INDEX_THRESHOLD,
56
56
  });
57
- // eslint-disable-next-line react-hooks/exhaustive-deps
58
57
  }, [nodes, levelFilter, skipCulling, isDragging, centerX, centerY, zoomLevel, canvasWidth, canvasHeight]);
59
58
  }
package/src/index.ts CHANGED
@@ -24,32 +24,26 @@ export type { IViewportStore } from './stores/useViewportStore';
24
24
 
25
25
  // Hooks
26
26
  export { useKonvaRenderer } from './hooks/useKonvaRenderer';
27
- export { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
27
+ export { useLayerChoreography } from './hooks/useLayerChoreography';
28
+ export type { LayerChoreography, LayerChoreographyOptions } from './hooks/useLayerChoreography';
28
29
  export { useViewportNodes } from './hooks/useViewportNodes';
29
30
  export type { UseKonvaRendererOptions, UseKonvaRendererReturn } from './hooks/useKonvaRenderer';
30
- export type { LayerTransitionHandlers } from './hooks/useKonvaLayerTransition';
31
31
 
32
- // Animations
33
- export { KonvaLayerTransitionAnimator, konvaLayerTransitionAnimator } from './animations/konvaLayerTransition';
34
- export type { AnimatorViewport, TransitionCallbacks } from './animations/konvaLayerTransition';
32
+ export type { AnimatorViewport } from './hooks/useLayerChoreography';
35
33
 
36
- // Config helpers
34
+ // Design system: themes, comfort, tokens, geometry (generated from design-system/ in @visualli/core)
37
35
  export {
38
- ALL_BLOB_SHAPES,
39
- ACTIVE_BLOB_TYPES,
40
- BLOB_LAYER_CONFIG,
41
- NODE_LAYER_CONFIG,
42
- getBlobTypeForLayer,
43
- BLOB_TEXT_OFFSETS,
44
- drawBlobPath,
45
- buildBlobPathData,
46
- NODE_TEXT_BASE_FONT_PX,
47
- DESCRIPTION_TEXT_BASE_FONT_PX,
48
- computeNodeTextWorldScale,
49
- computeNodeTextScreenScale,
50
- computeOverlayScale,
51
- computeEdgeLabelScale,
52
- } from './config';
36
+ THEME_NAMES, TOPICS, DESIGN_SYSTEM_VERSION, TOKENS, METRICS,
37
+ resolveTheme, resolveComfort, ideaColor, ideaStyle, topicStyle, getTokens,
38
+ BLOB_SHAPES, RINGS, blobPath, blobRadius, edgePath, arrowPath, shapeForLevel,
39
+ IDEA, LABEL_GROWTH, ideaSize, labelGrowth,
40
+ } from '@visualli/core';
41
+ export type { ThemeName, ThemeInput, TopicName, Comfort } from '@visualli/core';
42
+ export { useDesign, useReaderEnv } from './design/useDesign';
43
+ export { makeDesign, resolveDesign, DEFAULT_DESIGN } from './design/design';
44
+ export type { Design, DesignProps } from './design/design';
45
+ export { ensureDesignSystemStyles, loadCanvasFonts } from './design/runtime';
46
+ export type { DesignSystemAssets } from './design/runtime';
53
47
 
54
48
  // Utils
55
49
  export {
@@ -70,12 +64,18 @@ export { default as KonvaNodeLayer } from './components/KonvaNodeLayer';
70
64
  export { default as KonvaEdgeLayer } from './components/KonvaEdgeLayer';
71
65
  export { default as KonvaContainer } from './components/KonvaContainer';
72
66
  export { default as KonvaContainerLayer } from './components/KonvaContainerLayer';
67
+ export { default as KonvaContainerLabelLayer } from './components/KonvaContainerLabelLayer';
73
68
  export { default as NavigationStack } from './components/NavigationStack';
74
69
  export { default as ZoomControls } from './components/ZoomControls';
75
- export { default as SketchyBoxKonva } from './components/SketchyBoxKonva';
70
+ export { PeekCard, TermCard, DepthTrail, splitTerms, paintVars } from './components/Overlays';
71
+ export { A11yLayer } from './components/A11yLayer';
76
72
  export type { NavStackEntry } from './components/NavigationStack';
73
+ export type { PeekCardProps, TrailEntry, DepthTrailProps, IdeaPaint } from './components/Overlays';
74
+ export { ideaMeasure } from './design/measure';
77
75
  export type { KonvaNodeProps } from './components/KonvaNode';
78
76
  export type { KonvaEdgeProps } from './components/KonvaEdge';
79
77
  export type { KonvaStageProps } from './components/KonvaStage';
80
78
  export type { ContainerGroup } from './components/KonvaContainerLayer';
79
+ export { layoutContainers, measurePill, drawHull, drawPill } from './design/containers';
80
+ export type { PlacedContainer, PlacedPill } from './design/containers';
81
81
 
@@ -4,14 +4,14 @@ import { create } from 'zustand';
4
4
  import { selectionStore, renderConfigStore } from '@visualli/core';
5
5
  import type { ISelectionStore, IRenderConfigStore } from '@visualli/core';
6
6
 
7
- export const useSelectionStore = create<ISelectionStore>((set, get, store) => {
7
+ export const useSelectionStore = create<ISelectionStore>((set) => {
8
8
  selectionStore.subscribe((state) => set(state));
9
9
  return selectionStore.getState();
10
10
  });
11
11
 
12
12
  // ─── Render Config Store ──────────────────────────────────────────────────────
13
13
 
14
- export const useRenderConfigStore = create<IRenderConfigStore>((set, get, store) => {
14
+ export const useRenderConfigStore = create<IRenderConfigStore>((set) => {
15
15
  renderConfigStore.subscribe((state) => set(state));
16
16
  return renderConfigStore.getState();
17
17
  });