@visualli/react 0.1.6 → 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 -2032
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +355 -97
  5. package/dist/index.d.ts +355 -97
  6. package/dist/index.js +2005 -2094
  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 +453 -408
  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
@@ -1,36 +1,37 @@
1
1
  // ─── VisualliCanvas ───────────────────────────────────────────────────────────
2
2
  //
3
- // Main canvas component for rendering a VisualliDocument.
4
- // Matches the visualli.ai enhanced canvas implementation:
3
+ // Main canvas component for rendering a VisualliDocument, drawn with the Visualli
4
+ // design system (tokens, fonts and geometry from design-system/):
5
+ // • Konva canvas for ideas, rings and connectors; DOM overlays for the peek,
6
+ // term cards, depth trail and controls (the design system's own CSS)
5
7
  // • Stage-level hit detection via RBush spatial index (no Konva hit-canvas)
6
- // • Node description tooltip (sketchy-box style)
7
- // • Chromatic immersion background (color from current node)
8
- // • Auto-zoom navigation (zoom in → child layer, zoom out → parent layer)
9
- // • Professional layer transitions (zoom in / zoom out animations)
10
- // • Vertical left-side navigation stack
11
- // • Zoom controls (icon-based, top-right)
12
- // • Context menu (right-click to go back)
8
+ // • 8 themes + comfort settings (readable type, larger text, reduced motion)
9
+ // • Step inside / back out by idea, peek, depth trail, Escape or Backspace — zoom and pinch never navigate
10
+ // • Layer transitions with the design system's motion (CSS-transform driven, no canvas redraws while animating)
11
+ // • A visually hidden DOM mirror of the visible ideas for screen readers / Tab
13
12
  //
14
- // NOT included (SDK-only, no generation UI):
15
- // • Sidebar, search, generation
16
- // • Semantic anchor tooltips (special word meanings)
17
- // • Branding logo
18
- // • Help overlay / view-source panel
13
+ // NOT included (read-only viewer): editing, generation, chat, sources, export.
19
14
 
20
- import React, { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
15
+ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
21
16
  import type Konva from 'konva';
22
- import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection } from '@visualli/core';
17
+ import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection, Comfort, ThemeInput } from '@visualli/core';
23
18
  import {
24
19
  parseVisualliFile,
25
20
  getNodesForLayer,
26
- getThemeBackground,
27
- darkenHexColor,
21
+ getSemanticAnchors,
22
+ ideaColor,
23
+ motionVars,
24
+ nodeRadii,
25
+ ringsFor,
26
+ peekPosition,
27
+ TRAIL,
28
28
  RBushSpatialIndex,
29
- NODE_HEIGHT,
30
- TEXT_LABEL_HIDE_BELOW_ZOOM,
31
- ZOOM_NAV_IN_THRESHOLD,
32
- ZOOM_NAV_OUT_THRESHOLD,
33
- ZOOM_MIN,
29
+ GESTURE,
30
+ pinchZoom,
31
+ zoomAround,
32
+ VIEW,
33
+ PEEK,
34
+ SHEET,
34
35
  } from '@visualli/core';
35
36
 
36
37
  import { useNodeStore } from './stores/useNodeStore';
@@ -38,43 +39,25 @@ import { useViewportStore } from './stores/useViewportStore';
38
39
  import { useSelectionStore } from './stores/stores';
39
40
 
40
41
  import { useKonvaRenderer } from './hooks/useKonvaRenderer';
41
- import { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
42
+ import { useLayerChoreography } from './hooks/useLayerChoreography';
43
+ import { RevealClock } from './design/choreography';
42
44
 
43
45
  import KonvaStage from './components/KonvaStage';
44
46
  import KonvaNodeLayer from './components/KonvaNodeLayer';
45
47
  import KonvaEdgeLayer from './components/KonvaEdgeLayer';
46
48
  import KonvaContainerLayer from './components/KonvaContainerLayer';
49
+ import KonvaContainerLabelLayer from './components/KonvaContainerLabelLayer';
47
50
  import NavigationStack, { type NavStackEntry } from './components/NavigationStack';
48
- import ZoomControls from './components/ZoomControls';
49
- import SketchyBoxKonva from './components/SketchyBoxKonva';
51
+ import { ZoomControls, PeekCard, PeekSheet } from './components/Overlays';
52
+ import { A11yLayer } from './components/A11yLayer';
50
53
 
51
54
  import { getChildLayerForNode, calculateFitView, getConnectionsForLayer, getContainersForLayer } from './utils/layerNavigation';
52
55
  import type { ContainerGroup } from './components/KonvaContainerLayer';
53
- import type { AnimatorViewport } from './animations/konvaLayerTransition';
54
- import { DESCRIPTION_TEXT_BASE_FONT_PX } from './config/textScaling';
55
- import { useVisualli } from './context/VisualliContext';
56
-
57
- // Helper to convert hex color to rgba with transparency
58
- function hexToRgba(hex: string, alpha: number): string {
59
- // Remove # if present
60
- hex = hex.replace(/^#/, '');
61
-
62
- // Parse hex values
63
- let r: number, g: number, b: number;
64
- if (hex.length === 3) {
65
- r = parseInt(hex[0] + hex[0], 16);
66
- g = parseInt(hex[1] + hex[1], 16);
67
- b = parseInt(hex[2] + hex[2], 16);
68
- } else if (hex.length === 6) {
69
- r = parseInt(hex.substring(0, 2), 16);
70
- g = parseInt(hex.substring(2, 4), 16);
71
- b = parseInt(hex.substring(4, 6), 16);
72
- } else {
73
- return `rgba(240, 237, 230, ${alpha})`; // fallback
74
- }
75
-
76
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
77
- }
56
+ import type { AnimatorViewport } from './hooks/useLayerChoreography';
57
+ import { useDesign } from './design/useDesign';
58
+ import { ideaMeasure } from './design/measure';
59
+ import { paintVars, type IdeaPaint } from './components/Overlays';
60
+ import { ensureDesignSystemStyles, loadCanvasFonts, type DesignSystemAssets } from './design/runtime';
78
61
 
79
62
  // ── Helpers ───────────────────────────────────────────────────────────────────
80
63
 
@@ -103,6 +86,16 @@ function layerLabel(doc: VisualliDocument, layer: VisualliLayer, layerId: string
103
86
  return layer.description ?? layerId;
104
87
  }
105
88
 
89
+ /** Colour of the idea a layer opens from — the idea's own colour, including the sibling-order topic of an uncoloured
90
+ * idea (the design system's TRAIL); the root layer is the map itself, TRAIL.rootTopic. */
91
+ function layerPaint(doc: VisualliDocument, layer: VisualliLayer): IdeaPaint {
92
+ const parentLayer = layer.parentLayerId ? doc.layers.get(layer.parentLayerId) : null;
93
+ const index = parentLayer ? parentLayer.nodes.findIndex(n => n.id === layer.parentNodeId) : -1;
94
+ if (!parentLayer || index < 0) return { topic: TRAIL.rootTopic };
95
+ const { topic, custom } = ideaColor(parentLayer.nodes[index]!.data.color, index);
96
+ return topic ? { topic } : { custom: custom! };
97
+ }
98
+
106
99
  function getRootLayerId(doc: VisualliDocument): string | null {
107
100
  for (const [id, layer] of doc.layers) {
108
101
  if (!layer.parentLayerId) return id;
@@ -126,13 +119,51 @@ export interface VisualliCanvasProps {
126
119
  * Component handles all fetching and parsing automatically.
127
120
  */
128
121
  visualliFile?: File | string;
122
+ /**
123
+ * Theme: one of the 8 design-system themes ('light', 'dark', 'focus-light', …), a family
124
+ * ('focus' | 'colorsafe' | 'contrast') or 'auto' (follow the reader). Default 'light'.
125
+ */
126
+ theme?: ThemeInput;
127
+ /** @deprecated use `theme`. true -> 'dark', false -> 'light'. Kept for one release. */
129
128
  isDark?: boolean;
129
+ /** Comfort settings: readable type, larger text, reduced motion. */
130
+ comfort?: Comfort;
131
+ /**
132
+ * How a layer arrives. 'gradual' (default): ideas bloom in one by one, then connectors draw
133
+ * (design-system motion). 'instant': everything fades in together, as with reduced motion.
134
+ */
135
+ reveal?: 'gradual' | 'instant';
136
+ /** Switch to the high-contrast theme under forced-colors. Default true. */
137
+ respectForcedColors?: boolean;
138
+ /**
139
+ * Interaction layout. 'auto' (default) follows the reader's input: touch devices get the design
140
+ * system's bottom-sheet peek and larger controls, pointer devices the floating peek on hover.
141
+ * Force one with 'touch' or 'pointer'.
142
+ */
143
+ layout?: 'auto' | 'touch' | 'pointer';
144
+ /**
145
+ * The map is the page (an app such as Visualli's web app): it takes every touch gesture, so the browser never
146
+ * zooms or scrolls the page — the design system's `.vi-map.is-app` (`touch-action: none`). Leave it off when the
147
+ * map is embedded in a scrolling page: one finger then still scrolls the page, two fingers pinch the map.
148
+ */
149
+ app?: boolean;
150
+ /**
151
+ * Where the zoom / fit controls sit. Default 'top-right', the design system's placement (`.vi-map__ctrls`,
152
+ * opposite the depth trail). 'bottom-right' moves them to the bottom corner for hosts that need the top edge;
153
+ * there they rise above the touch peek sheet while it's open.
154
+ */
155
+ controlsPosition?: 'top-right' | 'bottom-right';
156
+ /** Where the bundled Caveat font is served from (directory URL). Defaults to the copy in the npm package, via jsDelivr. */
157
+ fontBaseUrl?: DesignSystemAssets['fontBaseUrl'];
158
+ /** Set false when you load Kalam and Atkinson Hyperlegible yourself. */
159
+ loadWebFonts?: DesignSystemAssets['loadWebFonts'];
130
160
  chromaticImmersion?: boolean;
131
161
  onNodeClick?: (node: FlatNode) => void;
132
162
  onLayerChange?: (layerId: string, layer: VisualliLayer) => void;
163
+ onNodeHover?: (nodeId: string | null) => void;
133
164
 
134
165
  // Extension points for private features (implement in consuming app)
135
- renderOverlay?: (params: { isDark: boolean; containerWidth: number; containerHeight: number }) => React.ReactNode;
166
+ renderOverlay?: (params: { isDark: boolean; theme: string; containerWidth: number; containerHeight: number }) => React.ReactNode;
136
167
  renderNodeContent?: (params: {
137
168
  summary: string;
138
169
  nodeId: string;
@@ -149,7 +180,17 @@ export interface VisualliCanvasProps {
149
180
  // ── Component ─────────────────────────────────────────────────────────────────
150
181
 
151
182
  export default function VisualliCanvas(props: VisualliCanvasProps) {
152
- const { isDark = false, chromaticImmersion = false, onNodeClick, onLayerChange, renderOverlay, renderNodeContent, navigationStackTop = '16px', navigationStackLeft = '16px', className = '', style } = props;
183
+ const { chromaticImmersion = false, onNodeClick, onLayerChange, onNodeHover, renderOverlay, renderNodeContent, navigationStackTop, navigationStackLeft, className = '', style } = props;
184
+ // The host's hover callback, read through a ref so the stage's pointer handlers stay stable when it changes.
185
+ const onNodeHoverRef = useRef(onNodeHover);
186
+ onNodeHoverRef.current = onNodeHover;
187
+
188
+ // Design system: resolve theme + comfort, inject its CSS once, and hold the first draw until its fonts are loaded.
189
+ const design = useDesign({ theme: props.theme ?? (props.isDark === undefined ? 'light' : undefined), isDark: props.isDark, comfort: props.comfort, respectForcedColors: props.respectForcedColors });
190
+ const isDark = design.isDark;
191
+ const [fontSheet] = useState(() => ensureDesignSystemStyles({ fontBaseUrl: props.fontBaseUrl, loadWebFonts: props.loadWebFonts }));
192
+ const [fontsReady, setFontsReady] = useState(false);
193
+ useEffect(() => { let live = true; loadCanvasFonts(fontSheet).then(() => { if (live) setFontsReady(true); }); return () => { live = false; }; }, [fontSheet]);
153
194
 
154
195
  // Read file if provided (handles both File objects and string paths)
155
196
  const [fileText, setFileText] = useState<string | undefined>(undefined);
@@ -189,6 +230,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
189
230
  const [navStack, setNavStack] = useState<NavStackEntry[]>([]);
190
231
  const [currentLayerId, setCurrentLayerId] = useState<string | null>(null);
191
232
  const parentViewports = useRef<AnimatorViewport[]>([]);
233
+ /** The idea being stepped into: the depth trail entry takes its colour and label from it, so they always match the idea that was clicked. */
234
+ const stepTargetRef = useRef<{ paint: IdeaPaint; label: string } | null>(null);
192
235
  const [isTransitioning, setIsTransitioning] = useState(false);
193
236
  const isTransitioningRef = useRef(false);
194
237
 
@@ -198,21 +241,25 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
198
241
  if (!rootId) return;
199
242
  const rootLayer = doc.layers.get(rootId)!;
200
243
  setCurrentLayerId(rootId);
201
- setNavStack([{ layerId: rootId, layer: rootLayer, label: 'Home' }]);
244
+ // The trail starts with the map's title (the design system's DepthTrail).
245
+ setNavStack([{ layerId: rootId, layer: rootLayer, label: doc.meta?.title || 'Home', paint: { topic: TRAIL.rootTopic } }]);
202
246
  parentViewports.current = [];
203
247
  }, [doc]);
204
248
 
205
249
  // ── Active layer → FlatNodes ──────────────────────────────────────────────
250
+ // Ideas are sized for their labels (the design system's idea.ts) with the real label face, so sizes are taken once
251
+ // the fonts are in, and again when the face or the label scale changes (readable type, larger text).
252
+ const sizing = useMemo(() => ({ measure: ideaMeasure(design), labelScale: design.labelScale }), [design.fontHand, design.labelScale, design.comfort.readableType, fontsReady]); // eslint-disable-line react-hooks/exhaustive-deps
206
253
  const { flatNodes, connections, containers } = useMemo(() => {
207
254
  if (!doc || !currentLayerId) return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] };
208
255
  try {
209
256
  return {
210
- flatNodes: getNodesForLayer(doc, currentLayerId),
257
+ flatNodes: getNodesForLayer(doc, currentLayerId, sizing),
211
258
  connections: getConnectionsForLayer(doc, currentLayerId),
212
259
  containers: getContainersForLayer(doc, currentLayerId) as ContainerGroup[],
213
260
  };
214
261
  } catch { return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] }; }
215
- }, [doc, currentLayerId]);
262
+ }, [doc, currentLayerId, sizing]);
216
263
  const viewport = useViewportStore(s => ({
217
264
  centerX: s.centerX, centerY: s.centerY, zoomLevel: s.zoomLevel,
218
265
  canvasWidth: s.canvasWidth, canvasHeight: s.canvasHeight,
@@ -234,18 +281,28 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
234
281
  clearSel();
235
282
  }, [flatNodes, setNodes, clearSel]);
236
283
 
284
+ // ── Choreography: a layer's arrival (ideas bloom, connectors draw) ────────
285
+ const clock = useMemo(() => new RevealClock(), []);
286
+ const instantReveal = design.comfort.reducedMotion || props.reveal === 'instant';
287
+ useEffect(() => {
288
+ if (!fontsReady || flatNodes.length === 0) return;
289
+ clock.start(flatNodes, connections.length, instantReveal);
290
+ // Lift the step-inside veil two frames from now, once the new layer's first frame has been painted.
291
+ const r1 = requestAnimationFrame(() => { r2 = requestAnimationFrame(arrive); });
292
+ let r2 = 0;
293
+ return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); };
294
+ }, [flatNodes, fontsReady]); // eslint-disable-line react-hooks/exhaustive-deps
295
+ useEffect(() => clock.subscribe((running) => {
296
+ const el = containerRef.current;
297
+ if (el) el.dataset.viReveal = running ? 'running' : 'idle';
298
+ }), [clock]);
299
+ useEffect(() => () => clock.stop(), [clock]);
300
+
237
301
  // ── Canvas refs ───────────────────────────────────────────────────────────
238
302
  const containerRef = useRef<HTMLDivElement | null>(null);
239
303
  const stageRef = useRef<Konva.Stage | null>(null);
240
304
  const canvasWrapperRef = useRef<HTMLDivElement>(null);
241
305
 
242
- // ── GPU CSS-transform transition refs (matches visualli.ai) ───────────────
243
- // During animations we express viewport deltas as CSS transforms on Konva's
244
- // container div — zero canvas redraws, handled entirely by the GPU compositor.
245
- const baselineTransformRef = useRef<{ x: number; y: number; scaleX: number; scaleY: number } | null>(null);
246
- const konvaContentDivRef = useRef<HTMLDivElement | null>(null);
247
- const lastAnimViewportRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
248
- const zoomOutTargetRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
249
306
  // canvasSizeRef is kept in sync with the measured container size (not window).
250
307
  // Initialise to 0 — it will be updated synchronously before the first frame.
251
308
  const canvasSizeRef = useRef({ width: 0, height: 0 });
@@ -255,34 +312,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
255
312
  const dragMoveLatestRef = useRef<{ nodeId: string; x: number; y: number } | null>(null);
256
313
  const dragStartPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
257
314
 
258
- // ── Background color (with chromatic immersion support) ──────────────────
259
- const baseBgColor = isDark ? '#141412' : '#F0EDE6';
260
-
261
- // For chromatic immersion: get the parent node color for child layers
262
- const chromaticBgColor = useMemo(() => {
263
- if (!chromaticImmersion || !doc || !currentLayerId) return baseBgColor;
264
-
265
- const currentLayer = doc.layers.get(currentLayerId);
266
- if (!currentLayer) return baseBgColor;
267
-
268
- // Root layer always uses base background
269
- if (currentLayer.level === 0 || !currentLayer.parentNodeId) return baseBgColor;
270
-
271
- // For child layers, find the parent node's color
272
- const parentLayer = currentLayer.parentLayerId ? doc.layers.get(currentLayer.parentLayerId) : null;
273
- if (parentLayer) {
274
- const parentNode = parentLayer.nodes.find(n => n.id === currentLayer.parentNodeId);
275
- if (parentNode) {
276
- const nodeColor = parentNode.data.color || baseBgColor;
277
- // Add transparency to the parent node color for subtle effect
278
- return hexToRgba(nodeColor, 0.15);
279
- }
280
- }
281
-
282
- return baseBgColor;
283
- }, [chromaticImmersion, doc, currentLayerId, baseBgColor]);
284
-
285
- const bgColor = chromaticImmersion ? chromaticBgColor : baseBgColor;
315
+ // ── Chromatic immersion: the colour of the idea stepped into tints the canvas at `immersion-alpha` ──
316
+ const immersionFill = useMemo<string | null>(() => {
317
+ if (!chromaticImmersion || !doc || !currentLayerId) return null;
318
+ const layer = doc.layers.get(currentLayerId);
319
+ if (!layer || layer.level === 0 || !layer.parentNodeId) return null;
320
+ return paintVars(layerPaint(doc, layer)).fill;
321
+ }, [chromaticImmersion, doc, currentLayerId]);
286
322
 
287
323
  // ── Spatial index (for stage-level hit detection) ─────────────────────────
288
324
  const spatialIndexRef = useRef<RBushSpatialIndex | null>(null);
@@ -290,14 +326,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
290
326
  if (flatNodes.length === 0) { spatialIndexRef.current?.clear(); return; }
291
327
  const idx = new RBushSpatialIndex();
292
328
  idx.bulkLoad(flatNodes.map(n => {
293
- const blobHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (n.width / 2) * 0.74);
294
- return {
295
- nodeId: n.id,
296
- bounds: {
297
- minX: n.x - n.width / 2, minY: n.y - blobHalfH,
298
- maxX: n.x + n.width / 2, maxY: n.y + blobHalfH,
299
- },
300
- };
329
+ const { rx, ry } = nodeRadii(n);
330
+ return { nodeId: n.id, bounds: { minX: n.x - rx, minY: n.y - ry, maxX: n.x + rx, maxY: n.y + ry } };
301
331
  }));
302
332
  spatialIndexRef.current = idx;
303
333
  return () => { spatialIndexRef.current?.clear(); };
@@ -309,6 +339,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
309
339
  handleMouseDown: rendererHandleMouseDown,
310
340
  handleMouseMove: rendererHandleMouseMove,
311
341
  handleMouseUp: rendererHandleMouseUp,
342
+ cancelPan: rendererCancelPan,
312
343
  canvasWidth,
313
344
  canvasHeight,
314
345
  } = useKonvaRenderer({ containerRef, stageRef });
@@ -351,16 +382,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
351
382
  if (node) {
352
383
  const oldPos = dragStartPosRef.current.get(nodeId);
353
384
  if (oldPos) {
354
- const oldHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
385
+ const { rx, ry } = nodeRadii(node);
355
386
  spatialIndexRef.current.remove(nodeId, {
356
- minX: oldPos.x - node.width / 2, minY: oldPos.y - oldHalfH,
357
- maxX: oldPos.x + node.width / 2, maxY: oldPos.y + oldHalfH,
387
+ minX: oldPos.x - rx, minY: oldPos.y - ry,
388
+ maxX: oldPos.x + rx, maxY: oldPos.y + ry,
358
389
  });
359
390
  }
360
- const newHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
391
+ const { rx, ry } = nodeRadii(node);
361
392
  spatialIndexRef.current.insert(nodeId, {
362
- minX: x - node.width / 2, minY: y - newHalfH,
363
- maxX: x + node.width / 2, maxY: y + newHalfH,
393
+ minX: x - rx, minY: y - ry,
394
+ maxX: x + rx, maxY: y + ry,
364
395
  });
365
396
  }
366
397
  }
@@ -377,17 +408,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
377
408
  // relative to the renderer's actual bounding box.
378
409
  const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
379
410
  const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
380
- const { centerX, centerY } = calculateFitView(flatNodes, cw, ch);
381
- // Root layer: always use 1.0 zoom to match visualli.ai reference behaviour.
382
- // calculateFitZoom for a single node or dense layout on a 1920-default canvas
383
- // produces zoom > 2 which triggers the auto-zoom-in threshold immediately.
384
- const isRootLayer = navStack.length <= 1;
385
- // Root layer: use 1.0 zoom to match visualli.ai reference behaviour.
386
- // Child layers use the bounding-box fit zoom (already clamped to [0.4, 5]).
387
- const zoomLevel = isRootLayer ? 1.0 : calculateFitView(flatNodes, cw, ch).zoomLevel;
411
+ // Every layer, the root included, fits the same way (the design system's fit; never beyond VIEW.fitMax).
412
+ const { centerX, centerY, zoomLevel } = calculateFitView(flatNodes, cw, ch, containers);
413
+ // Zoom limits (VIEW) apply relative to the fit, as in the design system's map.
414
+ useViewportStore.getState().setFit(zoomLevel, centerX, centerY);
388
415
  setCenter(centerX, centerY);
389
416
  setZoom(zoomLevel);
390
- }, [flatNodes, navStack.length, setCenter, setZoom]);
417
+ }, [flatNodes, containers, setCenter, setZoom]);
391
418
 
392
419
  // Keep ref in sync after every render so async callbacks always use latest closure
393
420
  fitToScreenRef.current = fitToScreen;
@@ -408,14 +435,22 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
408
435
  // Stores the target nav-back index so onSwapBack can read it
409
436
  const navBackTargetRef = useRef<number>(-1);
410
437
 
411
- const { zoomIntoLayer, zoomOutToParent, isTransitioning: isAnimating } = useKonvaLayerTransition({
438
+ const { stepInside: diveInto, backOut, arrive, isTransitioning: isAnimating } = useLayerChoreography({
412
439
  stageRef,
413
440
  canvasWrapperRef,
441
+ reducedMotion: design.comfort.reducedMotion || props.reveal === 'instant',
414
442
  onSwapLayer: (childLayerId) => {
415
443
  const childLayer = doc?.layers.get(childLayerId);
416
444
  if (!childLayer || !doc) return;
417
445
  setCurrentLayerId(childLayerId);
418
- setNavStack(prev => [...prev, { layerId: childLayerId, layer: childLayer, label: layerLabel(doc, childLayer, childLayerId) }]);
446
+ const target = stepTargetRef.current;
447
+ stepTargetRef.current = null;
448
+ setNavStack(prev => [...prev, {
449
+ layerId: childLayerId,
450
+ layer: childLayer,
451
+ label: target?.label || layerLabel(doc, childLayer, childLayerId),
452
+ paint: target?.paint ?? layerPaint(doc, childLayer),
453
+ }]);
419
454
  onLayerChange?.(childLayerId, childLayer);
420
455
  },
421
456
  onSwapBack: () => {
@@ -433,88 +468,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
433
468
  setIsTransitioning(false);
434
469
  isTransitioningRef.current = false;
435
470
  // After every layer switch, snap to the fit view so zoom is always clean.
436
- // rAF ensures React has committed new node data before we compute the fit.
437
471
  requestAnimationFrame(() => { fitToScreenRef.current(); });
438
472
  },
439
- // GPU CSS-transform callbacks (matching visualli.ai exactly):
440
- onViewportFrame: (vp) => {
441
- lastAnimViewportRef.current = vp;
442
- const baseline = baselineTransformRef.current;
443
- const contentDiv = konvaContentDivRef.current;
444
- const { width, height } = canvasSizeRef.current;
445
- if (baseline && contentDiv) {
446
- const targetX = width / 2 - vp.centerX * vp.zoomLevel;
447
- const targetY = height / 2 - vp.centerY * vp.zoomLevel;
448
- const ratio = vp.zoomLevel / baseline.scaleX;
449
- const tx = targetX - baseline.x * ratio;
450
- const ty = targetY - baseline.y * ratio;
451
- contentDiv.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
452
- } else {
453
- const stage = stageRef.current;
454
- if (stage) {
455
- stage.x(width / 2 - vp.centerX * vp.zoomLevel);
456
- stage.y(height / 2 - vp.centerY * vp.zoomLevel);
457
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
458
- stage.batchDraw();
459
- }
460
- }
461
- },
462
- onViewportFinal: (vp) => {
463
- if (konvaContentDivRef.current) konvaContentDivRef.current.style.transform = '';
464
- const stage = stageRef.current;
465
- if (stage) {
466
- const { width, height } = canvasSizeRef.current;
467
- stage.x(width / 2 - vp.centerX * vp.zoomLevel);
468
- stage.y(height / 2 - vp.centerY * vp.zoomLevel);
469
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
470
- stage.getLayers().forEach(l => l.drawScene());
471
- }
472
- setCenter(vp.centerX, vp.centerY);
473
- setZoom(vp.zoomLevel);
474
- },
475
- onPhaseChange: (phase) => {
476
- const stage = stageRef.current;
477
- const contentDiv = konvaContentDivRef.current;
478
- if (stage && contentDiv && (phase === 'swap' || phase === 'zoom-back')) {
479
- contentDiv.style.transform = '';
480
- const vp = phase === 'zoom-back' ? zoomOutTargetRef.current : lastAnimViewportRef.current;
481
- if (vp) {
482
- const { width, height } = canvasSizeRef.current;
483
- const nx = width / 2 - vp.centerX * vp.zoomLevel;
484
- const ny = height / 2 - vp.centerY * vp.zoomLevel;
485
- stage.x(nx); stage.y(ny);
486
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
487
- stage.getLayers().forEach(l => l.drawScene());
488
- baselineTransformRef.current = { x: nx, y: ny, scaleX: vp.zoomLevel, scaleY: vp.zoomLevel };
489
- }
490
- }
491
- },
492
- onTransitionLifecycle: (phase) => {
493
- const stage = stageRef.current;
494
- if (phase === 'start') {
495
- if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'opacity, transform';
496
- if (stage) {
497
- baselineTransformRef.current = { x: stage.x(), y: stage.y(), scaleX: stage.scaleX(), scaleY: stage.scaleY() };
498
- const container = stage.container();
499
- const cd = container?.querySelector('.konvajs-content') as HTMLDivElement | null;
500
- if (cd) {
501
- konvaContentDivRef.current = cd;
502
- cd.style.transformOrigin = '0 0';
503
- cd.style.willChange = 'transform';
504
- }
505
- stage.getLayers().forEach(l => { l.listening(false); });
506
- }
507
- } else {
508
- if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'auto';
509
- if (konvaContentDivRef.current) { konvaContentDivRef.current.style.willChange = 'auto'; konvaContentDivRef.current = null; }
510
- baselineTransformRef.current = null;
511
- zoomOutTargetRef.current = null;
512
- if (stage) {
513
- stage.getLayers().forEach(l => { l.listening(true); });
514
- stage.batchDraw();
515
- }
516
- }
517
- },
518
473
  });
519
474
 
520
475
  const handleNavigate = useCallback((nodeId: string) => {
@@ -533,7 +488,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
533
488
  if (!childLayerId) {
534
489
  return;
535
490
  }
536
- const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId); } catch { return []; } })();
491
+ const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId, sizing); } catch { return []; } })();
537
492
  if (childNodes.length === 0) {
538
493
  return;
539
494
  }
@@ -546,19 +501,15 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
546
501
  // rather than the arbitrary zoom that happened to trigger the navigation.
547
502
  const cw = canvasSizeRef.current.width || 800;
548
503
  const ch = canvasSizeRef.current.height || 600;
549
- const fitVp = calculateFitView(flatNodes, cw, ch);
550
- const isRootNow = navStack.length <= 1;
551
- parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: isRootNow ? 0.85 : fitVp.zoomLevel });
504
+ const fitVp = calculateFitView(flatNodes, cw, ch, containers);
505
+ parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: fitVp.zoomLevel });
552
506
 
507
+ stepTargetRef.current = { paint: { topic: node.topic, custom: node.custom }, label: node.title };
553
508
  setIsTransitioning(true);
554
509
  isTransitioningRef.current = true;
555
510
 
556
- zoomIntoLayer(
557
- doc, node, childLayerId, childNodes,
558
- node.color ?? getThemeBackground(isDark, 'secondary'),
559
- node.color ?? getThemeBackground(isDark, 'primary'),
560
- );
561
- }, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
511
+ diveInto(node, childLayerId, childNodes);
512
+ }, [doc, currentLayerId, nodes, isAnimating, diveInto, sizing, flatNodes, containers]);
562
513
 
563
514
  const handleNavigateBack = useCallback((targetIndex: number) => {
564
515
  if (targetIndex >= navStack.length - 1) return;
@@ -568,47 +519,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
568
519
  navBackTargetRef.current = targetIndex;
569
520
  setIsTransitioning(true);
570
521
  isTransitioningRef.current = true;
571
- zoomOutToParent(savedVp, getThemeBackground(isDark, 'primary'), getThemeBackground(isDark, 'secondary'));
572
- }, [navStack.length, isDark, isAnimating, zoomOutToParent]);
573
-
574
- // ── Auto-zoom navigation (Google Maps style) ──────────────────────────────
575
- const baseZoomRef = useRef(1.0);
576
- const lastZoomTransitionRef = useRef(0);
577
- const ZOOM_COOLDOWN = 1000;
578
-
579
- useEffect(() => { baseZoomRef.current = 1.0; }, [currentLayerId]);
580
-
581
- useEffect(() => {
582
- if (isTransitioningRef.current || isAnimating()) return;
583
- const now = Date.now();
584
- if (now - lastZoomTransitionRef.current < ZOOM_COOLDOWN) return;
585
- const relative = viewport.zoomLevel / baseZoomRef.current;
586
-
587
- if (relative >= ZOOM_NAV_IN_THRESHOLD) {
588
- // Zoom in threshold — drill into closest node
589
- let closest: FlatNode | null = null;
590
- let minDist = Infinity;
591
- for (const node of nodes.values()) {
592
- const dx = node.x - viewport.centerX;
593
- const dy = node.y - viewport.centerY;
594
- const d = dx * dx + dy * dy;
595
- if (d < minDist) { minDist = d; closest = node; }
596
- }
597
- if (closest) {
598
- const childLayer = doc ? getChildLayerForNode(doc, closest.id, currentLayerId ?? '') : null;
599
- if (childLayer) {
600
- lastZoomTransitionRef.current = now;
601
- handleNavigate(closest.id);
602
- baseZoomRef.current = 1.0;
603
- }
604
- }
605
- } else if (relative < ZOOM_NAV_OUT_THRESHOLD && navStack.length > 1) {
606
- // Zoom out threshold — go back to parent
607
- lastZoomTransitionRef.current = now;
608
- handleNavigateBack(navStack.length - 2);
609
- baseZoomRef.current = 1.0;
610
- }
611
- }, [viewport.zoomLevel, viewport.centerX, viewport.centerY, nodes, doc, currentLayerId, navStack, isAnimating, handleNavigate, handleNavigateBack]); // eslint-disable-line react-hooks/exhaustive-deps
522
+ backOut(savedVp);
523
+ }, [navStack.length, isAnimating, backOut]);
612
524
 
613
525
  // ── Stage-level hit detection ─────────────────────────────────────────────
614
526
  const toWorldCoords = useCallback((stage: Konva.Stage, pointer: { x: number; y: number }) => {
@@ -679,9 +591,55 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
679
591
  return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', onKey); };
680
592
  }, [canvasContextMenu]);
681
593
 
594
+ const selectedNodeId = useSelectionStore(st => st.selectedId);
595
+ const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
596
+ const anchors = useMemo(() => (doc ? getSemanticAnchors(doc) : []), [doc]);
597
+
598
+ // ── Touch mode + pinned peek (bottom sheet) ───────────────────────────────
599
+ // Touch browsers also fire compatibility mouse events after a tap; those are ignored so
600
+ // they can neither reopen nor close the sheet.
601
+ const [autoTouch, setAutoTouch] = useState<boolean>(() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(hover: none)').matches);
602
+ const touchMode = props.layout === 'touch' ? true : props.layout === 'pointer' ? false : autoTouch;
603
+ const touchModeRef = useRef(touchMode);
604
+ touchModeRef.current = touchMode;
605
+ const lastTouchAtRef = useRef(0);
606
+ const emptyTapRef = useRef<{ x: number; y: number } | null>(null);
607
+ const [pinnedId, setPinnedId] = useState<string | null>(null);
608
+ const [sheetExpanded, setSheetExpanded] = useState(false);
609
+ const isEmulatedMouse = (evt: Event) => evt.type.startsWith('mouse') && performance.now() - lastTouchAtRef.current < 700;
610
+ /** Client coordinates of a mouse or touch event. */
611
+ const clientOf = (evt: MouseEvent | TouchEvent) => {
612
+ const t = ('touches' in evt && (evt.touches[0] ?? evt.changedTouches[0])) || (evt as MouseEvent);
613
+ return { x: t.clientX, y: t.clientY };
614
+ };
615
+ const pinnedIdRef = useRef<string | null>(null);
616
+ pinnedIdRef.current = pinnedId;
617
+ /** Collapsed sheet height: the design system's SHEET — a share of the map, at most SHEET.maxHeight. */
618
+ const sheetHeight = Math.min(SHEET.maxHeight, Math.round((viewport.canvasHeight || canvasSizeRef.current.height || 600) * SHEET.heightRatio));
619
+
620
+ /** Open the bottom sheet for an idea, scrolling the map so the idea stays visible above it. */
621
+ const openSheet = (node: FlatNode) => {
622
+ setPinnedId(node.id);
623
+ setSheetExpanded(false);
624
+ setHoveredNode(null);
625
+ const vp = useViewportStore.getState();
626
+ const ch = canvasSizeRef.current.height || vp.canvasHeight;
627
+ const sh = Math.min(SHEET.maxHeight, Math.round(ch * SHEET.heightRatio));
628
+ const screenY = (node.y - vp.centerY) * vp.zoomLevel + ch / 2;
629
+ const reach = nodeRadii(node).ry * vp.zoomLevel;
630
+ if (screenY + reach > ch - sh - 12 || screenY - reach < 0) vp.setCenter(vp.centerX, node.y + sh / (2 * vp.zoomLevel));
631
+ };
632
+ // Close the sheet when the layer changes.
633
+ useEffect(() => { setPinnedId(null); }, [currentLayerId]);
634
+
682
635
  // ── Stage event handlers ──────────────────────────────────────────────────
683
636
  const handleStageMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
684
- if (e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
637
+ if (isEmulatedMouse(e.evt)) return;
638
+ const isTouch = e.evt.type.startsWith('touch');
639
+ if (isTouch) lastTouchAtRef.current = performance.now();
640
+ if (props.layout === undefined || props.layout === 'auto') setAutoTouch(isTouch);
641
+ // Touch events carry no `button`: a touch is always a primary press.
642
+ if (!isTouch && e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
685
643
  const stage = e.target.getStage();
686
644
  if (!stage) { rendererHandleMouseDown(e); return; }
687
645
  const pointer = stage.getPointerPosition();
@@ -694,7 +652,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
694
652
  select(nodeId);
695
653
  stageActiveNodeIdRef.current = nodeId;
696
654
  stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
697
- stageMouseDownPosRef.current = { x: e.evt.clientX, y: e.evt.clientY };
655
+ stageMouseDownPosRef.current = clientOf(e.evt);
698
656
  stageDragCommittedRef.current = false;
699
657
  setPressedNodeId(nodeId);
700
658
  setHoveredNode(null);
@@ -702,11 +660,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
702
660
  }
703
661
  }
704
662
  setHoveredNode(null);
663
+ emptyTapRef.current = clientOf(e.evt);
705
664
  isCanvasPanningRef.current = true;
706
665
  rendererHandleMouseDown(e);
707
- }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
666
+ }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown, props.layout]);
708
667
 
709
668
  const handleStageMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
669
+ if (isEmulatedMouse(e.evt)) return;
710
670
  const stage = e.target.getStage();
711
671
  if (!stage) { rendererHandleMouseMove(e); return; }
712
672
  const pointer = stage.getPointerPosition();
@@ -714,7 +674,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
714
674
  const { worldX, worldY } = toWorldCoords(stage, pointer);
715
675
 
716
676
  if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
717
- const moved = Math.hypot(e.evt.clientX - stageMouseDownPosRef.current.x, e.evt.clientY - stageMouseDownPosRef.current.y);
677
+ const cur = clientOf(e.evt);
678
+ const moved = Math.hypot(cur.x - stageMouseDownPosRef.current.x, cur.y - stageMouseDownPosRef.current.y);
718
679
  if (!stageDragCommittedRef.current && moved >= 5) {
719
680
  stageDragCommittedRef.current = true;
720
681
  isDraggingRef.current = true;
@@ -726,7 +687,6 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
726
687
  if (stageDragCommittedRef.current) {
727
688
  const offset = stageDragNodeOffsetRef.current;
728
689
  handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
729
- stage.container().style.cursor = 'grabbing';
730
690
  return;
731
691
  }
732
692
  return;
@@ -746,18 +706,17 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
746
706
  if (hovered !== pointerNodeIdRef.current) {
747
707
  pointerNodeIdRef.current = hovered;
748
708
  setPointerNodeId(hovered);
709
+ onNodeHoverRef.current?.(hovered);
749
710
  }
750
- if (hovered) {
751
- stage.container().style.cursor = 'pointer';
752
- } else {
753
- stage.container().style.cursor = 'grab';
754
- }
711
+ // Cursors as the design system's: an idea is a button (pointer); the canvas keeps the default.
712
+ stage.container().style.cursor = hovered ? 'pointer' : '';
755
713
  }
756
714
 
757
715
  rendererHandleMouseMove(e);
758
716
  }, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
759
717
 
760
718
  const handleStageMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
719
+ if (isEmulatedMouse(e.evt)) return;
761
720
  setPressedNodeId(null);
762
721
  if (stageActiveNodeIdRef.current) {
763
722
  const nodeId = stageActiveNodeIdRef.current;
@@ -777,7 +736,14 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
777
736
  const node = nodes.get(nodeId);
778
737
  if (node) {
779
738
  const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? '') : null;
780
- if (childLayer) {
739
+ if (touchModeRef.current && childLayer && pinnedIdRef.current === nodeId) {
740
+ // Touch: tapping the idea whose sheet is open steps inside (same as the sheet's Step inside).
741
+ setPinnedId(null);
742
+ handleNavigate(nodeId);
743
+ } else if (touchModeRef.current && (node.description || childLayer)) {
744
+ // Touch: the first tap opens the sheet; nothing flashes or navigates by itself.
745
+ openSheet(node);
746
+ } else if (childLayer) {
781
747
  handleNavigate(nodeId);
782
748
  }
783
749
  onNodeClick?.(node);
@@ -790,9 +756,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
790
756
  stageDragCommittedRef.current = false;
791
757
  return;
792
758
  }
759
+ // A tap (not a pan) on empty canvas dismisses the sheet.
760
+ const tap = emptyTapRef.current;
761
+ emptyTapRef.current = null;
762
+ if (tap && pinnedIdRef.current) {
763
+ const end = clientOf(e.evt);
764
+ if (!Number.isFinite(end.x) || Math.hypot(end.x - tap.x, end.y - tap.y) < 6) setPinnedId(null);
765
+ }
793
766
  isCanvasPanningRef.current = false;
794
767
  rendererHandleMouseUp(e);
795
- }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
768
+ }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]); // eslint-disable-line react-hooks/exhaustive-deps
796
769
 
797
770
  const handleStageContextMenu = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
798
771
  e.evt.preventDefault();
@@ -803,41 +776,81 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
803
776
  setCanvasContextMenu({ x: localX, y: localY });
804
777
  }, [navStack.length]);
805
778
 
806
- // Touch passthrough
807
- const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
808
- handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseDown]);
809
- const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
810
- handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseMove]);
811
- const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
812
- handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseUp]);
813
-
814
- // Wheel with auto-nav guard on non-root layers
815
- const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
816
- const isZoomOut = e.evt.deltaY > 0;
817
- if (!isZoomOut) { rendererHandleWheel(e); return; }
818
-
819
- const liveZoom = useViewportStore.getState().zoomLevel;
820
- const projected = Math.max(ZOOM_MIN, liveZoom * (1 - e.evt.deltaY / 1000));
821
-
822
- // Root layer: no parent to navigate to — let the user zoom freely down to ZOOM_MIN
823
- // (do NOT clamp at ZOOM_NAV_OUT_THRESHOLD; content may require lower zoom to fit)
824
-
825
- if (navStack.length > 1 && projected <= ZOOM_NAV_OUT_THRESHOLD && !isTransitioningRef.current && !isAnimating()) {
826
- e.evt.preventDefault();
827
- const now = Date.now();
828
- if (now - lastZoomTransitionRef.current >= ZOOM_COOLDOWN) {
829
- lastZoomTransitionRef.current = now;
830
- handleNavigateBack(navStack.length - 2);
831
- baseZoomRef.current = 1.0;
832
- }
833
- return;
779
+ // ── Pinch to zoom (the design system's GESTURE, geometry/interaction.ts) ─────
780
+ // Two touch pointers zoom the contents around the point between them (pinchZoom / zoomAround, within VIEW's
781
+ // limits) and pan as they move together. Only the Konva world moves — the trail, controls, peek and sheet are
782
+ // DOM and never scale. A pinch never navigates, and never ends as a tap, a pan or an idea drag.
783
+ const touchPointsRef = useRef(new Map<number, { x: number; y: number }>());
784
+ const pinchRef = useRef<{ d0: number; m0: { x: number; y: number }; z0: number; c0: { x: number; y: number } } | null>(null);
785
+ const pinchEndedAtRef = useRef(-Infinity);
786
+ const [pinching, setPinching] = useState(false);
787
+ const localOf = (e: React.PointerEvent) => { const r = containerRef.current!.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
788
+ const onPinchDown = (e: React.PointerEvent) => {
789
+ if (!GESTURE.pinchZoom || !GESTURE.pointers.includes(e.pointerType)) return;
790
+ touchPointsRef.current.set(e.pointerId, localOf(e));
791
+ if (touchPointsRef.current.size !== 2 || isTransitioningRef.current || isAnimating()) return;
792
+ const [a, b] = [...touchPointsRef.current.values()];
793
+ const vp = useViewportStore.getState();
794
+ pinchRef.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), m0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, z0: vp.zoomLevel, c0: { x: vp.centerX, y: vp.centerY } };
795
+ // The first finger's press becomes part of the pinch: no tap, pan or drag comes of it.
796
+ rendererCancelPan();
797
+ isCanvasPanningRef.current = false;
798
+ emptyTapRef.current = null;
799
+ stageActiveNodeIdRef.current = null;
800
+ stageDragCommittedRef.current = false;
801
+ isDraggingRef.current = false;
802
+ setPressedNodeId(null);
803
+ setPinching(true);
804
+ };
805
+ const onPinchMove = (e: React.PointerEvent) => {
806
+ if (!touchPointsRef.current.has(e.pointerId)) return;
807
+ touchPointsRef.current.set(e.pointerId, localOf(e));
808
+ const p = pinchRef.current;
809
+ if (!p || touchPointsRef.current.size < 2) return;
810
+ const [a, b] = [...touchPointsRef.current.values()];
811
+ const fit = useViewportStore.getState().fitScale;
812
+ const z = pinchZoom(p.z0 / fit, p.d0, Math.hypot(a.x - b.x, a.y - b.y)) * fit; // VIEW's limits relative to the fit
813
+ const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
814
+ const cw = canvasSizeRef.current.width || containerRef.current?.clientWidth || 800;
815
+ const ch = canvasSizeRef.current.height || containerRef.current?.clientHeight || 600;
816
+ // screen = (world − centre) × zoom + size / 2, i.e. translation t = size / 2 − centre × zoom
817
+ const t = zoomAround({ x: cw / 2 - p.c0.x * p.z0, y: ch / 2 - p.c0.y * p.z0 }, p.z0, z, p.m0, GESTURE.twoFingerPan ? m : p.m0);
818
+ setZoom(z);
819
+ setCenter((cw / 2 - t.x) / z, (ch / 2 - t.y) / z);
820
+ };
821
+ const onPinchUp = (e: React.PointerEvent) => {
822
+ if (!touchPointsRef.current.delete(e.pointerId)) return;
823
+ if (pinchRef.current && touchPointsRef.current.size < 2) {
824
+ pinchRef.current = null;
825
+ pinchEndedAtRef.current = performance.now();
826
+ setPinching(false);
834
827
  }
835
- rendererHandleWheel(e);
836
- }, [rendererHandleWheel, navStack, setZoom, isAnimating, handleNavigateBack]);
828
+ };
829
+ /** True while two fingers pinch, and for a moment after: the lifting fingers are not a tap. */
830
+ const inPinch = () => pinchRef.current !== null || touchPointsRef.current.size > 1 || performance.now() - pinchEndedAtRef.current < 300;
831
+
832
+ // Touch passthrough (one finger); a pinch owns the touches while it lasts.
833
+ const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
834
+ if (inPinch() || e.evt.touches.length > 1) return;
835
+ handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>);
836
+ }, [handleStageMouseDown]);
837
+ const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
838
+ if (inPinch() || e.evt.touches.length > 1) return;
839
+ handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>);
840
+ }, [handleStageMouseMove]);
841
+ const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
842
+ if (inPinch()) { isCanvasPanningRef.current = false; return; }
843
+ handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>);
844
+ }, [handleStageMouseUp]);
845
+
846
+ // Wheel / trackpad: zoom within VIEW's limits. It never navigates — stepping inside and backing out are
847
+ // the design system's explicit actions (the idea, its peek, the depth trail, Escape / Backspace).
848
+ const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { rendererHandleWheel(e); }, [rendererHandleWheel]);
837
849
 
838
850
  // Canvas mouse move for description tooltip
839
851
  const lastHoverCheckRef = useRef(0);
840
852
  const handleCanvasMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
853
+ if (touchModeRef.current || isEmulatedMouse(e.nativeEvent)) return; // no hover peek on touch
841
854
  if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
842
855
  setHoveredNode(null);
843
856
  return;
@@ -882,7 +895,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
882
895
  if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
883
896
  clearTooltipTimerRef.current = setTimeout(() => {
884
897
  if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
885
- }, 150);
898
+ }, PEEK.hoverClose); // the design system's peek grace (geometry/interaction.ts)
886
899
  } else {
887
900
  if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; }
888
901
  }
@@ -894,12 +907,11 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
894
907
  keepTooltipOpenRef.current = event.detail.keep;
895
908
  };
896
909
 
897
- // @ts-ignore - custom event
898
- window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
910
+ // A custom event: its listener takes a CustomEvent, so it is cast to the DOM's EventListener.
911
+ window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen as EventListener);
899
912
 
900
913
  return () => {
901
- // @ts-ignore - custom event
902
- window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
914
+ window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen as EventListener);
903
915
  };
904
916
  }, []);
905
917
 
@@ -912,78 +924,126 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
912
924
  };
913
925
  }, []);
914
926
 
915
- // ── Zoom controls ─────────────────────────────────────────────────────────
916
- const handleZoomIn = useCallback(() => setZoom(Math.min(viewport.zoomLevel * 1.2, 5)), [viewport.zoomLevel, setZoom]);
917
- const handleZoomOut = useCallback(() => {
918
- // Non-root: allow zoom to ZOOM_MIN so the wheel-driven ZOOM_NAV_OUT_THRESHOLD
919
- // transition can still trigger via scroll; button just navigates zoom value.
920
- // Root: also ZOOM_MIN (no parent layer to navigate to, allow full zoom-out).
921
- setZoom(Math.max(viewport.zoomLevel / 1.2, ZOOM_MIN));
922
- }, [viewport.zoomLevel, setZoom]);
923
-
924
- // Extension rendering removed - not part of open spec
927
+ // ── Activate an idea (click, or Enter/Space on its accessible button) ───────
928
+ const activateNode = useCallback((node: FlatNode) => {
929
+ const childLayer = doc ? getChildLayerForNode(doc, node.id, currentLayerId ?? '') : null;
930
+ if (touchModeRef.current && childLayer && pinnedIdRef.current === node.id) { setPinnedId(null); handleNavigate(node.id); }
931
+ else if (touchModeRef.current && (node.description || childLayer)) openSheet(node);
932
+ else if (childLayer) handleNavigate(node.id);
933
+ onNodeClick?.(node);
934
+ }, [doc, currentLayerId, handleNavigate, onNodeClick]);
935
+
936
+ const stepInside = useCallback((nodeId: string) => { setHoveredNode(null); handleNavigate(nodeId); }, [handleNavigate]);
937
+
938
+ // The peek can unmount while the pointer is on it (Step inside, layer change): no mouseleave
939
+ // fires then, so clear the "pointer is on the peek" flag whenever it closes.
940
+ useEffect(() => { if (!hoveredNode) tooltipHoverRef.current = false; }, [hoveredNode]);
941
+
942
+ // Keyboard, as the design system's canvas language: Escape or Backspace steps back out (Escape first closes a
943
+ // pinned peek); ⌘ / Ctrl + + / − / 0 zoom in, zoom out and fit (by VIEW.zoomStep, within VIEW's limits).
944
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
945
+ if ((e.target as HTMLElement).closest('input, textarea, [contenteditable="true"]')) return;
946
+ const cmd = e.metaKey || e.ctrlKey;
947
+ if (cmd && !e.altKey) {
948
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(viewport.zoomLevel * VIEW.zoomStep); }
949
+ else if (e.key === '-' || e.key === '_') { e.preventDefault(); setZoom(viewport.zoomLevel / VIEW.zoomStep); }
950
+ else if (e.key === '0') { e.preventDefault(); fitToScreen(); }
951
+ return;
952
+ }
953
+ if (e.altKey || e.shiftKey) return;
954
+ if (e.key === 'Escape' && pinnedIdRef.current) { e.preventDefault(); setPinnedId(null); }
955
+ else if ((e.key === 'Escape' || e.key === 'Backspace') && navStack.length > 1) { e.preventDefault(); handleNavigateBack(navStack.length - 2); }
956
+ }, [navStack.length, handleNavigateBack, setZoom, viewport.zoomLevel, fitToScreen]);
925
957
 
926
958
  // ── Render ────────────────────────────────────────────────────────────────
927
959
  if (!doc) {
928
960
  return (
929
- <div className={className} style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', background: bgColor, ...style }}>
930
- <span style={{ color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)', fontSize: 14 }}>No document</span>
961
+ <div className={`vi-map ${className}`} {...design.attrs} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
962
+ <span className="body-sm" style={{ color: 'var(--ink-muted)' }}>No document</span>
931
963
  </div>
932
964
  );
933
965
  }
934
966
 
935
967
  const hasBackOption = navStack.length > 1;
968
+ const currentLayerTitle = navStack[navStack.length - 1]?.label ?? '';
969
+ const peekNode = hoveredNode ? nodes.get(hoveredNode.nodeId) : undefined;
970
+ const pinnedNode = pinnedId ? nodes.get(pinnedId) : undefined;
971
+ const pinnedHasChild = !!(pinnedNode && getChildLayerForNode(doc, pinnedNode.id, currentLayerId ?? ''));
972
+ const compact = (viewport.canvasWidth || canvasSizeRef.current.width || 1024) < 560;
973
+ const medium = !compact && (viewport.canvasWidth || canvasSizeRef.current.width || 1024) < 900;
974
+ const peekHasChild = !!(peekNode && doc && getChildLayerForNode(doc, peekNode.id, currentLayerId ?? ''));
936
975
 
937
976
  return (
938
977
  <div
939
978
  ref={containerRef}
940
- className={className}
941
- style={{
942
- position: 'relative',
943
- overflow: 'hidden',
944
- userSelect: 'none',
945
- background: bgColor,
946
- width: '100%',
947
- height: '100%',
948
- ...style
949
- }}
979
+ className={`vi-map${touchMode ? ' is-touch' : ''}${compact ? ' is-compact' : ''}${medium ? ' is-medium' : ''}${pinching ? ' is-pinching' : ''}${props.app ? ' is-app' : ''} ${className}`}
980
+ {...design.attrs}
981
+ data-reveal={instantReveal ? 'instant' : undefined}
982
+ role="group"
983
+ aria-label={`${doc.meta?.title ?? 'Map'}${currentLayerTitle ? `, ${currentLayerTitle}` : ''}`}
984
+ onKeyDown={handleKeyDown}
985
+ // Focusable by pointer so Esc / + / - / 0 work after clicking the map (Tab order is the mirrored ideas).
986
+ tabIndex={-1}
987
+ onPointerDownCapture={(e) => { onPinchDown(e); if (!(e.target as HTMLElement).closest('button, a, input')) containerRef.current?.focus({ preventScroll: true }); }}
988
+ onPointerMoveCapture={onPinchMove}
989
+ onPointerUpCapture={onPinchUp}
990
+ onPointerCancelCapture={onPinchUp}
991
+ style={{ position: 'relative', overflow: 'hidden', userSelect: 'none', width: '100%', height: '100%', outline: 'none', ...(motionVars() as React.CSSProperties), ...style }}
950
992
  onMouseMove={handleCanvasMouseMove}
951
993
  onMouseLeave={() => {
952
- // Only close tooltip on mouse leave if not keeping it open
994
+ // Only close the peek on mouse leave if not keeping it open
953
995
  if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
954
996
  setHoveredNode(null);
955
997
  }
956
998
  }}
957
999
  >
958
- {/* Konva canvas — opacity driven imperatively during transitions */}
1000
+ {/* Chromatic immersion tint */}
1001
+ <div className="vi-map__immersion" style={{ background: immersionFill ?? 'transparent' }} aria-hidden="true" />
1002
+
1003
+ {/* Konva canvas — opacity driven imperatively during transitions. Held back until the fonts are ready. */}
959
1004
  <div ref={canvasWrapperRef} style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
960
- <KonvaStage
961
- ref={stageRef}
962
- onWheel={handleWheel}
963
- onMouseDown={handleStageMouseDown}
964
- onMouseMove={handleStageMouseMove}
965
- onMouseUp={handleStageMouseUp}
966
- onContextMenu={handleStageContextMenu}
967
- onTouchStart={handleStageTouchStart}
968
- onTouchMove={handleStageTouchMove}
969
- onTouchEnd={handleStageTouchEnd}
970
- >
971
- <KonvaContainerLayer nodes={flatNodes} containers={containers} isDark={isDark} />
972
- <KonvaEdgeLayer
973
- nodes={flatNodes}
974
- connections={connections}
975
- isDark={isDark}
976
- isDragging={isDraggingState}
977
- />
978
- <KonvaNodeLayer
979
- isTransitioning={isTransitioning}
980
- isDragging={isDraggingState}
981
- hoveredNodeId={pointerNodeId}
982
- pressedNodeId={pressedNodeId}
983
- />
984
- </KonvaStage>
1005
+ {fontsReady && (
1006
+ <KonvaStage
1007
+ ref={stageRef}
1008
+ onWheel={handleWheel}
1009
+ onMouseDown={handleStageMouseDown}
1010
+ onMouseMove={handleStageMouseMove}
1011
+ onMouseUp={handleStageMouseUp}
1012
+ onContextMenu={handleStageContextMenu}
1013
+ onTouchStart={handleStageTouchStart}
1014
+ onTouchMove={handleStageTouchMove}
1015
+ onTouchEnd={handleStageTouchEnd}
1016
+ >
1017
+ <KonvaContainerLayer nodes={flatNodes} containers={containers} connections={connections} design={design} />
1018
+ <KonvaEdgeLayer nodes={flatNodes} connections={connections} design={design} isDragging={isDraggingState} clock={clock} />
1019
+ <KonvaNodeLayer
1020
+ design={design}
1021
+ clock={clock}
1022
+ isTransitioning={isTransitioning}
1023
+ isDragging={isDraggingState}
1024
+ hoveredNodeId={pointerNodeId}
1025
+ pressedNodeId={pressedNodeId}
1026
+ selectedNodeId={selectedNodeId}
1027
+ focusedNodeId={focusedNodeId}
1028
+ touch={touchMode}
1029
+ peekedNodeId={touchMode ? pinnedId : null}
1030
+ />
1031
+ {/* Container names above connectors and ideas, so nothing runs over them. */}
1032
+ <KonvaContainerLabelLayer containers={containers} connections={connections} design={design} />
1033
+ </KonvaStage>
1034
+ )}
985
1035
  </div>
986
1036
 
1037
+ {/* Screen-reader / keyboard mirror of the visible ideas */}
1038
+ <A11yLayer
1039
+ nodes={flatNodes}
1040
+ label={`Ideas in ${currentLayerTitle || 'this map'}`}
1041
+ hasChildLayer={(id) => !!getChildLayerForNode(doc, id, currentLayerId ?? '')}
1042
+ onActivate={activateNode}
1043
+ onFocusNode={setFocusedNodeId}
1044
+ announcement={hasBackOption ? `Now inside ${currentLayerTitle}` : undefined}
1045
+ />
1046
+
987
1047
  {/* Context menu (right-click to go back) */}
988
1048
  {canvasContextMenu && hasBackOption && (
989
1049
  <div
@@ -991,7 +1051,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
991
1051
  aria-label="Canvas options"
992
1052
  style={{
993
1053
  position: 'absolute',
994
- zIndex: 60,
1054
+ zIndex: 'var(--z-menu)' as unknown as number,
995
1055
  pointerEvents: 'auto',
996
1056
  left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
997
1057
  top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`,
@@ -1001,16 +1061,9 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
1001
1061
  >
1002
1062
  <button
1003
1063
  type="button"
1004
- style={{
1005
- padding: '6px 16px',
1006
- borderRadius: '10px',
1007
- border: `1px solid ${isDark ? '#333330' : '#DDD9D0'}`,
1008
- background: isDark ? '#1C1C1A' : '#FAF8F4',
1009
- color: isDark ? '#F0EDE6' : '#1A1A18',
1010
- cursor: 'pointer',
1011
- fontSize: 13,
1012
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
1013
- }}
1064
+ role="menuitem"
1065
+ className="vi-iconbtn vi-iconbtn--surface"
1066
+ style={{ width: 'auto', padding: '6px var(--space-4)', boxShadow: 'var(--shadow-pop)' }}
1014
1067
  onClick={() => { setCanvasContextMenu(null); handleNavigateBack(navStack.length - 2); }}
1015
1068
  >
1016
1069
  Back
@@ -1018,70 +1071,62 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
1018
1071
  </div>
1019
1072
  )}
1020
1073
 
1021
- {/* Navigation stack (left, matches reference) */}
1022
- <NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} isDark={isDark} top={navigationStackTop} left={navigationStackLeft} />
1023
-
1024
- {/* Zoom controls (top-right) */}
1025
- <div data-help="zoom-controls" style={{ position: 'absolute', top: '24px', right: '16px', zIndex: 50, pointerEvents: 'auto' }}>
1026
- <ZoomControls isDark={isDark} />
1074
+ {/* Depth trail (top-left) */}
1075
+ <NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} top={navigationStackTop} left={navigationStackLeft} />
1076
+
1077
+ {/* Canvas controls: the design system's placement (top-right); 'bottom-right' only on request */}
1078
+ <div
1079
+ className="vi-map__ctrls"
1080
+ style={props.controlsPosition === 'bottom-right'
1081
+ ? { top: 'auto', bottom: pinnedNode && touchMode ? `calc(var(--space-4) + ${sheetHeight}px)` : 'var(--space-4)' }
1082
+ : undefined}
1083
+ >
1084
+ <ZoomControls onFit={fitToScreen} touch={touchMode} />
1027
1085
  </div>
1028
1086
 
1029
- {/* Custom overlay from consuming app (for private features like help button, chromatic bg, etc.) */}
1030
- {renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
1031
-
1032
- {/* Node description tooltip (sketchy-box, above node) - rendered before extensions so extensions appear on top */}
1033
- {hoveredNode && hoveredNodePosition && viewport.zoomLevel < 3 && viewport.zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM && (() => {
1034
- const nodeData = nodes.get(hoveredNode.nodeId);
1035
- const borderColor = nodeData?.color ? darkenHexColor(nodeData.color, 0.13) : '#5BA8D4';
1036
- const VP_PAD = 8;
1037
- const HALF_W = 160;
1038
- const APPROX_H = 90;
1039
- const containerW = canvasSizeRef.current.width || 800;
1040
- const safeLeft = Math.max(HALF_W + VP_PAD, Math.min(hoveredNodePosition.screenX, containerW - HALF_W - VP_PAD));
1041
- const rawTop = hoveredNodePosition.screenY - 60 * hoveredNodePosition.zoom;
1042
- const safeTop = Math.max(VP_PAD + APPROX_H, rawTop);
1087
+ {/* Custom overlay from the consuming app */}
1088
+ {renderOverlay?.({ isDark, theme: design.theme, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
1089
+
1090
+ {/* Touch: the peek is the design system's bottom sheet, pinned until dismissed */}
1091
+ {touchMode && pinnedNode && (
1092
+ <PeekSheet
1093
+ node={pinnedNode}
1094
+ anchors={anchors}
1095
+ height={sheetHeight}
1096
+ expanded={sheetExpanded}
1097
+ onToggleExpanded={() => setSheetExpanded((v) => !v)}
1098
+ onClose={() => setPinnedId(null)}
1099
+ onStepInside={pinnedHasChild ? () => { setPinnedId(null); handleNavigate(pinnedNode.id); } : undefined}
1100
+ renderContent={renderNodeContent ? (summary) => renderNodeContent({ summary, nodeId: pinnedNode.id, nodeColor: pinnedNode.color, zoom: viewport.zoomLevel }) : undefined}
1101
+ />
1102
+ )}
1103
+
1104
+ {/* Pointer: the peek floats over the hovered idea — the design system's card */}
1105
+ {!touchMode && hoveredNode && hoveredNodePosition && peekNode && (() => {
1106
+ // Beside the idea — right of it if it fits, else left — level with it (the design system's peekPosition).
1107
+ const rings = ringsFor(peekNode.branchCount);
1108
+ const half = nodeRadii(peekNode).rx * (rings.length ? rings[rings.length - 1]!.scale : 1) * hoveredNodePosition.zoom;
1109
+ const at = peekPosition({ x: hoveredNodePosition.screenX, y: hoveredNodePosition.screenY, half }, { w: canvasSizeRef.current.width || 800, h: canvasSizeRef.current.height || 600 });
1043
1110
  return (
1044
1111
  <div
1045
- data-node-tooltip="true"
1046
- style={{
1047
- position: 'absolute',
1048
- zIndex: 50,
1049
- left: safeLeft,
1050
- top: safeTop,
1051
- transform: 'translate(-50%, -100%)',
1052
- transformOrigin: 'bottom center',
1053
- pointerEvents: 'auto',
1054
- }}
1112
+ className="vi-map__fact"
1113
+ style={{ left: at.left, top: at.top, pointerEvents: 'auto', zIndex: 'var(--z-card)' as unknown as number }}
1055
1114
  onMouseEnter={() => { tooltipHoverRef.current = true; if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; } }}
1056
- onMouseLeave={() => {
1057
- tooltipHoverRef.current = false;
1058
- // Delay clearing to allow external extensions to request keeping tooltip open
1115
+ onMouseLeave={() => {
1116
+ tooltipHoverRef.current = false;
1117
+ // Delay clearing to allow external extensions to request keeping the peek open
1059
1118
  if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
1060
1119
  clearTooltipTimerRef.current = setTimeout(() => {
1061
1120
  if (!keepTooltipOpenRef.current) setHoveredNode(null);
1062
- }, 100);
1121
+ }, PEEK.hoverClose);
1063
1122
  }}
1064
1123
  >
1065
- <div style={{ maxWidth: '20rem', minWidth: '12rem', maxHeight: '15rem', overflowY: 'auto' }}>
1066
- <SketchyBoxKonva fill="#ffffff" stroke={borderColor} backStroke={borderColor} padding="1rem 1.25rem">
1067
- <p style={{
1068
- fontFamily: "'Playpen Sans', cursive",
1069
- fontSize: `${DESCRIPTION_TEXT_BASE_FONT_PX}px`,
1070
- fontWeight: 300,
1071
- color: '#000000',
1072
- textAlign: 'center',
1073
- lineHeight: 1.6,
1074
- margin: 0,
1075
- }}>
1076
- {renderNodeContent ? renderNodeContent({
1077
- summary: hoveredNode.summary,
1078
- nodeId: hoveredNode.nodeId,
1079
- nodeColor: nodeData?.color,
1080
- zoom: viewport.zoomLevel,
1081
- }) : hoveredNode.summary}
1082
- </p>
1083
- </SketchyBoxKonva>
1084
- </div>
1124
+ <PeekCard
1125
+ node={peekNode}
1126
+ anchors={anchors}
1127
+ onStepInside={peekHasChild ? () => stepInside(peekNode.id) : undefined}
1128
+ renderContent={renderNodeContent ? (summary) => renderNodeContent({ summary, nodeId: peekNode.id, nodeColor: peekNode.color, zoom: viewport.zoomLevel }) : undefined}
1129
+ />
1085
1130
  </div>
1086
1131
  );
1087
1132
  })()}