zelune 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to the **Zelune — React Native Visual Canvas & MCP** project are documented in this file.
4
4
 
5
+ ## [1.5.6] - 2026-10-24
6
+
7
+ ### Added
8
+ - **NativeWind / Tailwind CSS `className` Runtime Engine (`src/runtime/nativewindShim.ts`, `src/runtime/reactNativeShim.ts`, `src/runtime/MockNavigationContext.tsx`, `vite.config.ts`)**:
9
+ - Automatically loads the target project's `tailwind.config.js` / `tailwind.config.ts` (`colors`, `fontSize`, `borderRadius`, `spacing`, `opacity`, `fontFamily`) in `vite.config.ts` and injects `window.__ZELUNE_TAILWIND_THEME__`.
10
+ - Added `resolveTailwindClassNameToStyle` and `withNativeWind` wrapper across all React Native and Expo primitives (`View`, `Text`, `Pressable`, `TouchableOpacity`, `ScrollView`, `FlatList`, `KeyboardAvoidingView`, `TextInput`, `Image`, `SafeAreaView`, `LinearGradient`, `BlurView`, `createAnimatedComponent`) so screens and UI components styled with NativeWind `className="..."` (including M3 semantic tokens like `bg-surface-container-high`, `text-on-surface`, `text-headline-md`, `text-title-sm`, `bg-primary/10`, etc.) render with full CSS styles instead of unstyled stacks.
11
+
12
+ ### Fixed
13
+ - **Pinned Mobile Artboard `Dimensions` (`src/runtime/reactNativeShim.ts`, `src/runtime/MockNavigationContext.tsx`)**: Overrode `Dimensions.get('window' | 'screen')` and `useWindowDimensions()` to return `{ width: 393, height: 852, scale: 3, fontScale: 1 }` instead of the desktop browser window's `1440×900` dimensions, and upgraded the `expo-image` `<Image>` shim to use CSS `object-fit` inside an `overflow: hidden` container so carousel cards and hero images (e.g., on `HomeScreen`) size to exact 393×852 mobile proportions.
14
+ - **Wide Zoom-Out Range (`MIN_ZOOM = 0.08`), Multi-Row Grid Layout & Zero-Re-Render Pinch Zoom (`src/canvas/CanvasApp.tsx`)**:
15
+ - Lowered `MIN_ZOOM` from `25%` (`0.25`) to `8%` (`0.08`), updated `buildDefaultLayout` ("Grid" button) to arrange 6+ screens into a balanced multi-row grid, and enabled automatic fit-all on initial load so all screens can be viewed at once.
16
+ - Updated `applyFocalZoom` to keep full floating-point precision during active trackpad pinch/Ctrl-wheel zoom (eliminating subpixel focal-point oscillation), mutate `worldContainerRef.current.style.transform` and `zoomLabelRef.current.textContent` directly on the DOM without triggering 120Hz React re-renders of `<ScreenArtboard />`, and removed `willChange: 'transform'` bitmap caching so zooming in and out is completely smooth and vibration-free.
17
+
18
+ ## [1.5.5] - 2026-10-23
19
+
20
+ ### Added
21
+ - **Zod, React Hook Form & Sizing Utility Shims (`src/runtime/MockNavigationContext.tsx`, `plugins/viteReactNativeFallbackPlugin.ts`)**: Added `createSafeChainableProxy()` / `SAFE_CHAINABLE_PROXY` (wrapping a callable function target in a recursive `Proxy` for unlimited method/property chains), full Zod schema shims (`z`, `string`, `number`, `boolean`, `object`, `array`, `union`, `literal`, `record`, `tuple`, `any`, `unknown`, `optional`, `nullable`, `coerce`, `zodResolver`, `yupResolver`), React Hook Form shims (`useForm`, `useFormContext`, `FormProvider`, `useWatch`, `useFieldArray`, `Controller` supporting `render={({ field, fieldState, formState }) => ...}`), and React Native responsive sizing/classname helpers (`scale`, `verticalScale`, `moderateScale`, `RFValue`, `RFPercentage`, `wp`, `hp`, `clsx`, `cn`, `twMerge`).
22
+ - **Universal Unmounted React Context Fallback (`src/runtime/MockNavigationContext.tsx`, `src/main.tsx`)**: Wrapped `React.createContext` and `React.useContext` (after `react-native-web` initializes its internal contexts) with `SAFE_CONTEXT_FALLBACK` (pre-populated with preview `user`, `profile`, `session`, `isAuthenticated: true`, `isLoading: false`, `colors`, `items: []`, `signIn`, `signOut`, `logout`, `refresh`, etc.) so screens rendered outside their root `app/_layout.tsx` providers never throw `useAuth must be used within an AuthProvider` (or `useTheme` / `useCart` / `useNotifications` equivalents).
23
+
24
+ ### Fixed
25
+ - **Eliminated Canvas Panning Vibration / Jitter (`src/canvas/CanvasApp.tsx`, `src/canvas/ScreenArtboard.tsx`, `src/canvas/FlowArrowsLayer.tsx`)**:
26
+ - Updated `handleCanvasWheel` and `handleCanvasPointerDown` in `CanvasApp.tsx` to mutate `worldContainerRef.current.style.transform` (`translate3d(...) scale(...)`) directly on the DOM node with device-pixel rounding (`snapToDevicePixel`) and debounce `setPan` state synchronization to 120ms after movement stops, eliminating 120Hz React re-renders of all artboards while panning.
27
+ - Wrapped `ScreenArtboard` and `FlowArrowsLayer` in `React.memo`, scoped `selectedElement` and `inspectedElement` props to only the active screen, and suppressed `handlePointerMoveCapture` (`computeRelativeBox` / `setHoverBox`) while the canvas is moving or pointer buttons are pressed.
28
+ - Stabilized `useFocusEffect`, `useSegments`, `useQuery`, `useInfiniteQuery`, `useMutation`, and `useQueryClient` in `MockNavigationContext.tsx` so screens with inline `useFocusEffect` or `useEffect` dependencies do not trigger continuous re-render loops.
29
+ - **Light-Themed Screen Root Background & Image Overflow (`src/canvas/ScreenArtboard.tsx`)**: Set `backgroundColor: '#FFFFFF'`, `color: '#0F172A'`, `overflowY: 'auto'`, `overflowX: 'hidden'`, `minWidth: 0`, and `minHeight: 0` on the inner `<ScreenComponent />` mount container so screens that rely on React Native's default white root window background render with proper contrast and horizontally overflowing images are clipped to the 393px device frame.
30
+
5
31
  ## [1.5.4] - 2026-10-23
6
32
 
7
33
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zelune",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zelune",
3
3
  "displayName": "Zelune — React Native Visual Canvas",
4
- "version": "1.5.4",
4
+ "version": "1.5.6",
5
5
  "description": "Zelune MCP & Figma-style multi-screen React Native visual canvas with live surgical AST patching and navigation flow visualization for Google Antigravity.",
6
6
  "logo": "assets/logo.svg",
7
7
  "suggestedPrompts": [
@@ -484,53 +484,18 @@ export function viteReactNativeFallbackPlugin(
484
484
  `import { View } from 'react-native';`,
485
485
  `export * from ${JSON.stringify(normalizedShimPath)};`,
486
486
  `import * as KnownShims from ${JSON.stringify(normalizedShimPath)};`,
487
- `import DefaultShim from ${JSON.stringify(normalizedShimPath)};`,
488
- `function createSafeProxy() {`,
489
- ` const fnTarget = function(...args) {`,
490
- ` if (args.length === 1 && (typeof args[0] === 'number' || typeof args[0] === 'string')) {`,
491
- ` return args[0];`,
492
- ` }`,
493
- ` return proxy;`,
494
- ` };`,
495
- ` const arrTarget = [];`,
496
- ` const proxy = new Proxy(arrTarget, {`,
497
- ` get(target, prop) {`,
498
- ` if (prop === 'length') return 0;`,
499
- ` if (prop === Symbol.iterator) {`,
500
- ` return function* () {`,
501
- ` yield proxy;`,
502
- ` yield fnTarget;`,
503
- ` };`,
504
- ` }`,
505
- ` if (prop === Symbol.toPrimitive) {`,
506
- ` return (hint) => (hint === 'number' ? 0 : '');`,
507
- ` }`,
508
- ` if (prop === 'valueOf') return () => 0;`,
509
- ` if (prop === 'toString') return () => '';`,
510
- ` if (prop === 'then') {`,
511
- ` return (onFulfilled, onRejected) => Promise.resolve({ data: null, error: null }).then(onFulfilled, onRejected);`,
512
- ` }`,
513
- ` if (prop === 'catch') return () => Promise.resolve({ data: null, error: null });`,
514
- ` if (prop === 'finally') return (cb) => Promise.resolve({ data: null, error: null }).finally(cb);`,
515
- ` if (prop === 'isLoading' || prop === 'loading' || prop === 'isPending' || prop === 'isError' || prop === 'disabled') return false;`,
516
- ` if (prop === 'isReady' || prop === 'granted' || prop === 'isSuccess' || prop === ' isAuthenticated') return true;`,
517
- ` if (prop === 'error' || prop === 'user' || prop === 'session') return null;`,
518
- ` if (prop === 'status') return 'granted';`,
519
- ` if (prop === 'data' || prop === 'items' || prop === 'results' || prop === 'list') return [];`,
520
- ` if (prop === 'ref') return { current: null };`,
521
- ` if (prop === 'value') return 0;`,
522
- ` if (prop === 'map' || prop === 'filter' || prop === 'slice' || prop === 'flatMap') return () => [];`,
523
- ` if (prop === 'forEach') return () => undefined;`,
524
- ` if (prop === 'reduce') return (_fn, init) => init;`,
525
- ` if (prop in target) return Reflect.get(target, prop);`,
526
- ` return fnTarget;`,
527
- ` },`,
528
- ` });`,
529
- ` return proxy;`,
530
- `}`,
487
+ `import DefaultShim, { SAFE_CHAINABLE_PROXY } from ${JSON.stringify(normalizedShimPath)};`,
531
488
  `function createUniversalComponentStub(displayName) {`,
532
489
  ` const Comp = React.forwardRef(function UniversalNativeStub(props, ref) {`,
533
- ` return React.createElement(View, { style: props?.style, ref }, props?.children);`,
490
+ ` if (typeof props?.render === 'function') {`,
491
+ ` return props.render({`,
492
+ ` field: { name: props?.name ?? '', value: props?.defaultValue ?? '', onChange: () => undefined, onBlur: () => undefined, ref: () => undefined },`,
493
+ ` fieldState: { invalid: false, isTouched: false, isDirty: false, error: undefined },`,
494
+ ` formState: { errors: {}, isSubmitting: false, isValid: true },`,
495
+ ` });`,
496
+ ` }`,
497
+ ` const content = typeof props?.children === 'function' ? props.children(SAFE_CHAINABLE_PROXY) : props?.children;`,
498
+ ` return React.createElement(View, { style: props?.style, className: props?.className, ref }, content);`,
534
499
  ` });`,
535
500
  ` Comp.displayName = displayName;`,
536
501
  ` return new Proxy(Comp, {`,
@@ -539,21 +504,16 @@ export function viteReactNativeFallbackPlugin(
539
504
  ` if (typeof prop === 'string' && /^[A-Z]/.test(prop)) {`,
540
505
  ` return createUniversalComponentStub(displayName + '.' + prop);`,
541
506
  ` }`,
542
- ` return () => createSafeProxy();`,
507
+ ` return SAFE_CHAINABLE_PROXY;`,
543
508
  ` },`,
544
509
  ` });`,
545
510
  `}`,
546
511
  `function resolveStubExport(name) {`,
547
512
  ` if (name in KnownShims) return KnownShims[name];`,
548
513
  ` if (DefaultShim && name in DefaultShim) return DefaultShim[name];`,
549
- ` if (/^use[A-Z]/.test(name)) return () => createSafeProxy();`,
514
+ ` if (/^use[A-Z]/.test(name)) return () => SAFE_CHAINABLE_PROXY;`,
550
515
  ` if (/^[A-Z]/.test(name)) return createUniversalComponentStub(name);`,
551
- ` return (...args) => {`,
552
- ` if (args.length === 1 && (typeof args[0] === 'number' || typeof args[0] === 'string')) {`,
553
- ` return args[0];`,
554
- ` }`,
555
- ` return createSafeProxy();`,
556
- ` };`,
516
+ ` return SAFE_CHAINABLE_PROXY;`,
557
517
  `}`,
558
518
  `export default DefaultShim;`,
559
519
  ];
@@ -36,21 +36,33 @@ import type { PatchPayload, ReorderPayload } from '../server/astEngine';
36
36
  const DEFAULT_WIDTH = 393;
37
37
  const DEFAULT_HEIGHT = 852;
38
38
  const ARTBOARD_GAP = 130;
39
+ const ARTBOARD_ROW_GAP = 140;
39
40
  const INITIAL_OFFSET_X = 80;
40
41
  const INITIAL_OFFSET_Y = 70;
41
- const MIN_ZOOM = 0.25;
42
- const MAX_ZOOM = 1.75;
42
+ const MIN_ZOOM = 0.08;
43
+ const MAX_ZOOM = 2.0;
43
44
 
44
45
  function clampZoom(z: number): number {
45
- return Number(Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM).toFixed(3));
46
+ return Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM);
47
+ }
48
+
49
+ function snapToDevicePixel(v: number): number {
50
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
51
+ return Math.round(v * dpr) / dpr;
46
52
  }
47
53
 
48
54
  function buildDefaultLayout(screens: ScreenMetadata[]): ArtboardLayoutMap {
49
55
  const map: ArtboardLayoutMap = {};
56
+ const cols =
57
+ screens.length <= 5
58
+ ? Math.max(1, screens.length)
59
+ : Math.min(7, Math.ceil(Math.sqrt(screens.length * 1.6)));
50
60
  screens.forEach((screen, idx) => {
61
+ const col = idx % cols;
62
+ const row = Math.floor(idx / cols);
51
63
  map[screen.id] = {
52
- x: INITIAL_OFFSET_X + idx * (DEFAULT_WIDTH + ARTBOARD_GAP),
53
- y: INITIAL_OFFSET_Y,
64
+ x: INITIAL_OFFSET_X + col * (DEFAULT_WIDTH + ARTBOARD_GAP),
65
+ y: INITIAL_OFFSET_Y + row * (DEFAULT_HEIGHT + ARTBOARD_ROW_GAP),
54
66
  width: DEFAULT_WIDTH,
55
67
  height: DEFAULT_HEIGHT,
56
68
  };
@@ -128,18 +140,63 @@ export const CanvasApp: React.FC = () => {
128
140
  }>({ undoAvailable: 0, redoAvailable: 0 });
129
141
 
130
142
  const canvasViewportRef = useRef<HTMLDivElement | null>(null);
143
+ const worldContainerRef = useRef<HTMLDivElement | null>(null);
144
+ const zoomLabelRef = useRef<HTMLSpanElement | null>(null);
131
145
  const cameraTimerRef = useRef<number | null>(null);
146
+ const panSyncTimerRef = useRef<number | null>(null);
147
+ const hasInitialFitRef = useRef<boolean>(false);
132
148
 
133
149
  // Refs for synchronous access inside non-passive native wheel/gesture listeners
134
150
  const zoomRef = useRef<number>(zoom);
135
151
  const panRef = useRef<{ x: number; y: number }>(pan);
136
152
  useEffect(() => {
137
153
  zoomRef.current = zoom;
154
+ if (zoomLabelRef.current) {
155
+ zoomLabelRef.current.textContent = `${Math.round(zoom * 100)}%`;
156
+ }
138
157
  }, [zoom]);
139
158
  useEffect(() => {
140
159
  panRef.current = pan;
141
160
  }, [pan]);
142
161
 
162
+ const scheduleCameraStateSync = useCallback((commitImmediately = false) => {
163
+ if (panSyncTimerRef.current) {
164
+ window.clearTimeout(panSyncTimerRef.current);
165
+ panSyncTimerRef.current = null;
166
+ }
167
+ if (commitImmediately) {
168
+ setIsCameraAnimating(false);
169
+ setZoom(zoomRef.current);
170
+ setPan(panRef.current);
171
+ } else {
172
+ panSyncTimerRef.current = window.setTimeout(() => {
173
+ panSyncTimerRef.current = null;
174
+ setIsCameraAnimating(false);
175
+ setZoom(zoomRef.current);
176
+ setPan(panRef.current);
177
+ }, 140);
178
+ }
179
+ }, []);
180
+
181
+ const applyDirectPanTransform = useCallback(
182
+ (rawX: number, rawY: number, commitImmediately = false) => {
183
+ const nextPan = {
184
+ x: snapToDevicePixel(rawX),
185
+ y: snapToDevicePixel(rawY),
186
+ };
187
+ panRef.current = nextPan;
188
+
189
+ const worldEl = worldContainerRef.current;
190
+ if (worldEl) {
191
+ worldEl.style.transition = 'none';
192
+ worldEl.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${zoomRef.current})`;
193
+ }
194
+
195
+ scheduleCameraStateSync(commitImmediately);
196
+ },
197
+ [scheduleCameraStateSync]
198
+ );
199
+
143
200
  const triggerCameraTransition = useCallback(() => {
144
201
  if (cameraTimerRef.current) {
145
202
  window.clearTimeout(cameraTimerRef.current);
@@ -191,7 +248,34 @@ export const CanvasApp: React.FC = () => {
191
248
  setLayout((prev) => {
192
249
  const base = buildDefaultLayout(sorted);
193
250
  const saved = layoutJson.ok && layoutJson.layout ? layoutJson.layout : {};
194
- return { ...base, ...saved, ...prev };
251
+ const merged = { ...base, ...saved, ...prev };
252
+ if (!hasInitialFitRef.current && sorted.length > 0 && canvasViewportRef.current) {
253
+ hasInitialFitRef.current = true;
254
+ const rect = canvasViewportRef.current.getBoundingClientRect();
255
+ const positions = Object.values(merged);
256
+ if (positions.length > 0 && rect.width > 0 && rect.height > 0) {
257
+ const minX = Math.min(...positions.map((p) => p.x));
258
+ const minY = Math.min(...positions.map((p) => p.y));
259
+ const maxX = Math.max(...positions.map((p) => p.x + p.width));
260
+ const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
261
+ const contentWidth = maxX - minX + 140;
262
+ const contentHeight = maxY - minY + 140;
263
+ const nextZoom = clampZoom(
264
+ Math.min(rect.width / contentWidth, rect.height / contentHeight, 0.85)
265
+ );
266
+ const centerX = (minX + maxX) / 2;
267
+ const centerY = (minY + maxY) / 2;
268
+ const nextPan = {
269
+ x: Math.round(rect.width / 2 - centerX * nextZoom),
270
+ y: Math.round(rect.height / 2 - centerY * nextZoom),
271
+ };
272
+ zoomRef.current = nextZoom;
273
+ panRef.current = nextPan;
274
+ setZoom(nextZoom);
275
+ setPan(nextPan);
276
+ }
277
+ }
278
+ return merged;
195
279
  });
196
280
  }
197
281
  } catch (err) {
@@ -456,6 +540,7 @@ export const CanvasApp: React.FC = () => {
456
540
  if (nextZoom === prevZoom) return;
457
541
 
458
542
  // Pin the world coordinate directly under the user's cursor during two-finger pinch
543
+ // Keep full floating-point precision during active zoom gesture so (cursorX - worldX * nextZoom) never oscillates
459
544
  const cursorX = clientX - rect.left;
460
545
  const cursorY = clientY - rect.top;
461
546
  const worldX = (cursorX - prevPan.x) / prevZoom;
@@ -466,11 +551,16 @@ export const CanvasApp: React.FC = () => {
466
551
  y: cursorY - worldY * nextZoom,
467
552
  };
468
553
 
469
- setIsCameraAnimating(false);
470
554
  zoomRef.current = nextZoom;
471
555
  panRef.current = nextPan;
472
- setZoom(nextZoom);
473
- setPan(nextPan);
556
+ if (worldContainerRef.current) {
557
+ worldContainerRef.current.style.transition = 'none';
558
+ worldContainerRef.current.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${nextZoom})`;
559
+ }
560
+ if (zoomLabelRef.current) {
561
+ zoomLabelRef.current.textContent = `${Math.round(nextZoom * 100)}%`;
562
+ }
563
+ scheduleCameraStateSync(false);
474
564
  };
475
565
 
476
566
  // 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
@@ -483,13 +573,11 @@ export const CanvasApp: React.FC = () => {
483
573
  const zoomFactor = Math.exp(-e.deltaY * 0.0085);
484
574
  applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
485
575
  } else {
486
- setIsCameraAnimating(false);
487
- const nextPan = {
488
- x: panRef.current.x - e.deltaX,
489
- y: panRef.current.y - e.deltaY,
490
- };
491
- panRef.current = nextPan;
492
- setPan(nextPan);
576
+ applyDirectPanTransform(
577
+ panRef.current.x - e.deltaX,
578
+ panRef.current.y - e.deltaY,
579
+ false
580
+ );
493
581
  }
494
582
  };
495
583
 
@@ -537,7 +625,7 @@ export const CanvasApp: React.FC = () => {
537
625
  document.removeEventListener('gesturechange', handleGestureChange);
538
626
  document.removeEventListener('gestureend', handleGestureEnd);
539
627
  };
540
- }, []);
628
+ }, [applyDirectPanTransform, scheduleCameraStateSync]);
541
629
 
542
630
  // Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
543
631
  const focusOnScreen = useCallback(
@@ -562,48 +650,58 @@ export const CanvasApp: React.FC = () => {
562
650
  );
563
651
 
564
652
  // Fit all screens in viewport
565
- const handleFitAllScreens = useCallback(() => {
566
- const viewport = canvasViewportRef.current;
567
- if (!viewport || screens.length === 0) return;
568
- const rect = viewport.getBoundingClientRect();
653
+ const fitCameraToLayout = useCallback(
654
+ (targetLayout: ArtboardLayoutMap) => {
655
+ const viewport = canvasViewportRef.current;
656
+ if (!viewport) return;
657
+ const rect = viewport.getBoundingClientRect();
569
658
 
570
- const positions = Object.values(layout);
571
- if (positions.length === 0) return;
659
+ const positions = Object.values(targetLayout);
660
+ if (positions.length === 0) return;
572
661
 
573
- const minX = Math.min(...positions.map((p) => p.x));
574
- const minY = Math.min(...positions.map((p) => p.y));
575
- const maxX = Math.max(...positions.map((p) => p.x + p.width));
576
- const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
662
+ const minX = Math.min(...positions.map((p) => p.x));
663
+ const minY = Math.min(...positions.map((p) => p.y));
664
+ const maxX = Math.max(...positions.map((p) => p.x + p.width));
665
+ const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
577
666
 
578
- const contentWidth = maxX - minX + 140;
579
- const contentHeight = maxY - minY + 140;
667
+ const contentWidth = maxX - minX + 140;
668
+ const contentHeight = maxY - minY + 140;
580
669
 
581
- const scaleX = rect.width / contentWidth;
582
- const scaleY = rect.height / contentHeight;
583
- const nextZoom = Number(Math.min(Math.max(Math.min(scaleX, scaleY), 0.28), 1.0).toFixed(2));
670
+ const scaleX = rect.width / contentWidth;
671
+ const scaleY = rect.height / contentHeight;
672
+ const nextZoom = clampZoom(Math.min(scaleX, scaleY, 1.0));
584
673
 
585
- const centerX = (minX + maxX) / 2;
586
- const centerY = (minY + maxY) / 2;
674
+ const centerX = (minX + maxX) / 2;
675
+ const centerY = (minY + maxY) / 2;
676
+ const nextPan = {
677
+ x: Math.round(rect.width / 2 - centerX * nextZoom),
678
+ y: Math.round(rect.height / 2 - centerY * nextZoom),
679
+ };
587
680
 
588
- triggerCameraTransition();
589
- setZoom(nextZoom);
590
- setPan({
591
- x: Math.round(rect.width / 2 - centerX * nextZoom),
592
- y: Math.round(rect.height / 2 - centerY * nextZoom),
593
- });
594
- }, [layout, screens.length, triggerCameraTransition]);
681
+ triggerCameraTransition();
682
+ zoomRef.current = nextZoom;
683
+ panRef.current = nextPan;
684
+ setZoom(nextZoom);
685
+ setPan(nextPan);
686
+ },
687
+ [triggerCameraTransition]
688
+ );
595
689
 
596
- // Reset layout to clean horizontal grid
690
+ const handleFitAllScreens = useCallback(() => {
691
+ fitCameraToLayout(layout);
692
+ }, [fitCameraToLayout, layout]);
693
+
694
+ // Reset layout to clean multi-row grid and fit in viewport
597
695
  const handleResetGridLayout = useCallback(() => {
598
696
  const reset = buildDefaultLayout(screens);
599
- triggerCameraTransition();
600
697
  setLayout(reset);
698
+ fitCameraToLayout(reset);
601
699
  void fetch('/api/layout', {
602
700
  method: 'POST',
603
701
  headers: { 'Content-Type': 'application/json' },
604
702
  body: JSON.stringify(reset),
605
703
  });
606
- }, [screens, triggerCameraTransition]);
704
+ }, [screens, fitCameraToLayout]);
607
705
 
608
706
  // Move single artboard
609
707
  const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
@@ -630,7 +728,14 @@ export const CanvasApp: React.FC = () => {
630
728
  [focusOnScreen]
631
729
  );
632
730
 
633
- // Canvas background pointer pan (0ms direct manipulation override)
731
+ const handleSelectFlow = useCallback(
732
+ (edge: NavigationEdge) => {
733
+ focusOnScreen(edge.toScreen);
734
+ },
735
+ [focusOnScreen]
736
+ );
737
+
738
+ // Canvas background pointer pan (0ms direct DOM manipulation override)
634
739
  const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
635
740
  if (e.button !== 0 && e.button !== 1) return;
636
741
  setIsCameraAnimating(false);
@@ -638,20 +743,20 @@ export const CanvasApp: React.FC = () => {
638
743
 
639
744
  const startX = e.clientX;
640
745
  const startY = e.clientY;
641
- const startPanX = pan.x;
642
- const startPanY = pan.y;
746
+ const startPanX = panRef.current.x;
747
+ const startPanY = panRef.current.y;
643
748
 
644
749
  const handleMove = (moveEvent: PointerEvent) => {
645
- const nextPan = {
646
- x: startPanX + (moveEvent.clientX - startX),
647
- y: startPanY + (moveEvent.clientY - startY),
648
- };
649
- panRef.current = nextPan;
650
- setPan(nextPan);
750
+ applyDirectPanTransform(
751
+ startPanX + (moveEvent.clientX - startX),
752
+ startPanY + (moveEvent.clientY - startY),
753
+ false
754
+ );
651
755
  };
652
756
 
653
757
  const handleUp = () => {
654
758
  setIsPanning(false);
759
+ applyDirectPanTransform(panRef.current.x, panRef.current.y, true);
655
760
  window.removeEventListener('pointermove', handleMove);
656
761
  window.removeEventListener('pointerup', handleUp);
657
762
  };
@@ -913,6 +1018,7 @@ export const CanvasApp: React.FC = () => {
913
1018
  </button>
914
1019
 
915
1020
  <span
1021
+ ref={zoomLabelRef}
916
1022
  className="tabular-nums"
917
1023
  style={{
918
1024
  minWidth: 46,
@@ -1299,13 +1405,13 @@ export const CanvasApp: React.FC = () => {
1299
1405
 
1300
1406
  {/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
1301
1407
  <div
1408
+ ref={worldContainerRef}
1302
1409
  style={{
1303
1410
  position: 'absolute',
1304
1411
  top: 0,
1305
1412
  left: 0,
1306
1413
  transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
1307
1414
  transformOrigin: '0 0',
1308
- willChange: 'transform',
1309
1415
  transition: isCameraAnimating
1310
1416
  ? 'transform var(--duration-camera) var(--ease-out-expo)'
1311
1417
  : 'none',
@@ -1317,7 +1423,7 @@ export const CanvasApp: React.FC = () => {
1317
1423
  layout={layout}
1318
1424
  activeScreenId={activeScreenId}
1319
1425
  highlightedScreenId={highlightedScreenId}
1320
- onSelectFlow={(edge: NavigationEdge) => focusOnScreen(edge.toScreen)}
1426
+ onSelectFlow={handleSelectFlow}
1321
1427
  />
1322
1428
  )}
1323
1429
 
@@ -1329,6 +1435,7 @@ export const CanvasApp: React.FC = () => {
1329
1435
  height: DEFAULT_HEIGHT,
1330
1436
  };
1331
1437
  const ScreenComponent = SCREEN_COMPONENTS[screen.id];
1438
+ const isSelectedScreen = selectedElement?.screenId === screen.id;
1332
1439
 
1333
1440
  return (
1334
1441
  <ScreenArtboard
@@ -1340,8 +1447,9 @@ export const CanvasApp: React.FC = () => {
1340
1447
  mode={mode}
1341
1448
  isActiveScreen={activeScreenId === screen.id}
1342
1449
  isHighlightedScreen={highlightedScreenId === screen.id}
1343
- selectedElement={selectedElement}
1344
- inspectedElement={inspectedData}
1450
+ isCanvasMoving={isPanning}
1451
+ selectedElement={isSelectedScreen ? selectedElement : null}
1452
+ inspectedElement={isSelectedScreen ? inspectedData : null}
1345
1453
  onSelectElement={handleSelectElement}
1346
1454
  onSelectScreen={setActiveScreenId}
1347
1455
  onFocusScreen={focusOnScreen}
@@ -11,7 +11,7 @@ interface FlowArrowsLayerProps {
11
11
 
12
12
  const ARTBOARD_HEADER_OFFSET = 38;
13
13
 
14
- export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = ({
14
+ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
15
15
  screens,
16
16
  layout,
17
17
  activeScreenId,
@@ -194,4 +194,4 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = ({
194
194
  })}
195
195
  </svg>
196
196
  );
197
- };
197
+ });
@@ -29,6 +29,7 @@ interface ScreenArtboardProps {
29
29
  mode: CanvasMode;
30
30
  isActiveScreen: boolean;
31
31
  isHighlightedScreen: boolean;
32
+ isCanvasMoving?: boolean;
32
33
  selectedElement: SelectedElementRef | null;
33
34
  inspectedElement: InspectedElement | null;
34
35
  onSelectElement: (ref: SelectedElementRef) => void;
@@ -83,7 +84,7 @@ function escapeAstPathSelector(astPath: string): string {
83
84
  : astPath.replace(/["\\]/g, '\\$&');
84
85
  }
85
86
 
86
- export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
87
+ export const ScreenArtboard: React.FC<ScreenArtboardProps> = React.memo(({
87
88
  screen,
88
89
  ScreenComponent,
89
90
  position,
@@ -91,6 +92,7 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
91
92
  mode,
92
93
  isActiveScreen,
93
94
  isHighlightedScreen,
95
+ isCanvasMoving = false,
94
96
  selectedElement,
95
97
  inspectedElement,
96
98
  onSelectElement,
@@ -732,8 +734,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
732
734
  };
733
735
 
734
736
  // Inspect Mode pointer handlers
737
+ useEffect(() => {
738
+ if (isCanvasMoving) {
739
+ setHoverBox(null);
740
+ }
741
+ }, [isCanvasMoving]);
742
+
735
743
  const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
736
- if (mode !== 'inspect' || activeGizmoAction) return;
744
+ if (mode !== 'inspect' || activeGizmoAction || isCanvasMoving || e.buttons !== 0) return;
737
745
  const targetEl = findNearestAgyElement(e.target, viewportRef.current);
738
746
  if (!targetEl) {
739
747
  setHoverBox(null);
@@ -992,6 +1000,12 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
992
1000
  flexDirection: 'column',
993
1001
  height: '100%',
994
1002
  width: '100%',
1003
+ minHeight: 0,
1004
+ minWidth: 0,
1005
+ backgroundColor: '#FFFFFF',
1006
+ color: '#0F172A',
1007
+ overflowY: 'auto',
1008
+ overflowX: 'hidden',
995
1009
  }}
996
1010
  >
997
1011
  <ScreenComponent />
@@ -1474,4 +1488,4 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
1474
1488
  </div>
1475
1489
  </div>
1476
1490
  );
1477
- };
1491
+ });
package/src/main.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
+ import './runtime/MockNavigationContext';
3
4
  import { CanvasApp } from './canvas/CanvasApp';
4
5
  import './styles.css';
5
6