zelune 0.0.0-stage → 1.5.1

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.
@@ -0,0 +1,1288 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import {
3
+ MousePointer,
4
+ Play,
5
+ GitCommit,
6
+ Undo2,
7
+ Redo2,
8
+ ZoomIn,
9
+ ZoomOut,
10
+ Maximize,
11
+ LayoutGrid,
12
+ Layers,
13
+ RefreshCw,
14
+ PanelLeftClose,
15
+ PanelLeftOpen,
16
+ PanelRightClose,
17
+ PanelRightOpen,
18
+ Sliders,
19
+ } from 'lucide-react';
20
+ import { SCREEN_COMPONENTS, SCREEN_ORDER } from '../screens';
21
+ import { ScreenArtboard } from './ScreenArtboard';
22
+ import { FlowArrowsLayer } from './FlowArrowsLayer';
23
+ import { InspectorPanel } from './InspectorPanel';
24
+ import type {
25
+ ArtboardLayoutMap,
26
+ CanvasMode,
27
+ InspectedElement,
28
+ NavigationEdge,
29
+ ScreenMetadata,
30
+ SelectedElementRef,
31
+ } from './types';
32
+ import type { PatchPayload, ReorderPayload } from '../server/astEngine';
33
+
34
+ const DEFAULT_WIDTH = 393;
35
+ const DEFAULT_HEIGHT = 852;
36
+ const ARTBOARD_GAP = 130;
37
+ const INITIAL_OFFSET_X = 80;
38
+ const INITIAL_OFFSET_Y = 70;
39
+ const MIN_ZOOM = 0.25;
40
+ const MAX_ZOOM = 1.75;
41
+
42
+ function clampZoom(z: number): number {
43
+ return Number(Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM).toFixed(3));
44
+ }
45
+
46
+ function buildDefaultLayout(screens: ScreenMetadata[]): ArtboardLayoutMap {
47
+ const map: ArtboardLayoutMap = {};
48
+ screens.forEach((screen, idx) => {
49
+ map[screen.id] = {
50
+ x: INITIAL_OFFSET_X + idx * (DEFAULT_WIDTH + ARTBOARD_GAP),
51
+ y: INITIAL_OFFSET_Y,
52
+ width: DEFAULT_WIDTH,
53
+ height: DEFAULT_HEIGHT,
54
+ };
55
+ });
56
+ return map;
57
+ }
58
+
59
+ export const CanvasApp: React.FC = () => {
60
+ const [screens, setScreens] = useState<ScreenMetadata[]>([]);
61
+ const [projectName, setProjectName] = useState<string>('Google Artifact');
62
+ const [layout, setLayout] = useState<ArtboardLayoutMap>({});
63
+ const [mode, setMode] = useState<CanvasMode>('inspect');
64
+ const [showFlows, setShowFlows] = useState<boolean>(true);
65
+
66
+ // Collapsible Left (Screen Rail) & Right (AST Inspector) Panels
67
+ const [isLeftPanelOpen, setIsLeftPanelOpen] = useState<boolean>(true);
68
+ const [isRightPanelOpen, setIsRightPanelOpen] = useState<boolean>(true);
69
+
70
+ const [pan, setPan] = useState<{ x: number; y: number }>({ x: 20, y: 10 });
71
+ const [zoom, setZoom] = useState<number>(0.68);
72
+ const [isPanning, setIsPanning] = useState<boolean>(false);
73
+ const [isCameraAnimating, setIsCameraAnimating] = useState<boolean>(false);
74
+
75
+ const [activeScreenId, setActiveScreenId] = useState<string | null>('Home');
76
+ const [highlightedScreenId, setHighlightedScreenId] = useState<string | null>(null);
77
+ const [selectedElement, setSelectedElement] = useState<SelectedElementRef | null>(null);
78
+ const [inspectedData, setInspectedData] = useState<InspectedElement | null>(null);
79
+
80
+ const [isInspecting, setIsInspecting] = useState<boolean>(false);
81
+ const [isSaving, setIsSaving] = useState<boolean>(false);
82
+ const [statusMessage, setStatusMessage] = useState<string | null>(null);
83
+ const [historyState, setHistoryState] = useState<{
84
+ undoAvailable: number;
85
+ redoAvailable: number;
86
+ }>({ undoAvailable: 0, redoAvailable: 0 });
87
+
88
+ const canvasViewportRef = useRef<HTMLDivElement | null>(null);
89
+ const cameraTimerRef = useRef<number | null>(null);
90
+
91
+ // Refs for synchronous access inside non-passive native wheel/gesture listeners
92
+ const zoomRef = useRef<number>(zoom);
93
+ const panRef = useRef<{ x: number; y: number }>(pan);
94
+ useEffect(() => {
95
+ zoomRef.current = zoom;
96
+ }, [zoom]);
97
+ useEffect(() => {
98
+ panRef.current = pan;
99
+ }, [pan]);
100
+
101
+ const triggerCameraTransition = useCallback(() => {
102
+ if (cameraTimerRef.current) {
103
+ window.clearTimeout(cameraTimerRef.current);
104
+ }
105
+ setIsCameraAnimating(true);
106
+ cameraTimerRef.current = window.setTimeout(() => {
107
+ setIsCameraAnimating(false);
108
+ }, 390);
109
+ }, []);
110
+
111
+ const sortScreensByOrder = useCallback((rawScreens: ScreenMetadata[]) => {
112
+ return [...rawScreens].sort((a, b) => {
113
+ const idxA = SCREEN_ORDER.indexOf(a.id);
114
+ const idxB = SCREEN_ORDER.indexOf(b.id);
115
+ if (idxA === -1 && idxB === -1) return a.id.localeCompare(b.id);
116
+ if (idxA === -1) return 1;
117
+ if (idxB === -1) return -1;
118
+ return idxA - idxB;
119
+ });
120
+ }, []);
121
+
122
+ const fetchScreens = useCallback(async () => {
123
+ try {
124
+ const [screensRes, layoutRes] = await Promise.all([
125
+ fetch('/api/screens'),
126
+ fetch('/api/layout'),
127
+ ]);
128
+ const screensJson = (await screensRes.json()) as {
129
+ ok: boolean;
130
+ projectName?: string;
131
+ screens: ScreenMetadata[];
132
+ history?: { undoAvailable: number; redoAvailable: number };
133
+ };
134
+ const layoutJson = (await layoutRes.json()) as {
135
+ ok: boolean;
136
+ layout: ArtboardLayoutMap | null;
137
+ };
138
+
139
+ if (screensJson.ok) {
140
+ if (screensJson.projectName) {
141
+ setProjectName(screensJson.projectName);
142
+ }
143
+ const sorted = sortScreensByOrder(screensJson.screens);
144
+ setScreens(sorted);
145
+ if (screensJson.history) {
146
+ setHistoryState(screensJson.history);
147
+ }
148
+
149
+ setLayout((prev) => {
150
+ const base = buildDefaultLayout(sorted);
151
+ const saved = layoutJson.ok && layoutJson.layout ? layoutJson.layout : {};
152
+ return { ...base, ...saved, ...prev };
153
+ });
154
+ }
155
+ } catch (err) {
156
+ console.error('Failed to load screens metadata:', err);
157
+ }
158
+ }, [sortScreensByOrder]);
159
+
160
+ useEffect(() => {
161
+ void fetchScreens();
162
+ }, [fetchScreens]);
163
+
164
+ // Fetch AST inspection details when an element is selected
165
+ const inspectSelectedElement = useCallback(async (ref: SelectedElementRef) => {
166
+ setIsInspecting(true);
167
+ try {
168
+ const params = new URLSearchParams({
169
+ file: ref.file,
170
+ astPath: ref.astPath,
171
+ line: String(ref.line),
172
+ });
173
+ const res = await fetch(`/api/inspect?${params.toString()}`);
174
+ const data = (await res.json()) as {
175
+ ok: boolean;
176
+ element?: InspectedElement;
177
+ history?: { undoAvailable: number; redoAvailable: number };
178
+ error?: string;
179
+ };
180
+ if (data.ok && data.element) {
181
+ setInspectedData(data.element);
182
+ if (data.history) setHistoryState(data.history);
183
+ }
184
+ } catch (err) {
185
+ console.error('Inspect error:', err);
186
+ } finally {
187
+ setIsInspecting(false);
188
+ }
189
+ }, []);
190
+
191
+ const handleSelectElement = useCallback(
192
+ (ref: SelectedElementRef) => {
193
+ setSelectedElement(ref);
194
+ setActiveScreenId(ref.screenId);
195
+ setIsRightPanelOpen(true);
196
+ void inspectSelectedElement(ref);
197
+ },
198
+ [inspectSelectedElement]
199
+ );
200
+
201
+ // Surgical AST Patch handler
202
+ const handlePatchElement = useCallback(
203
+ async (payload: PatchPayload) => {
204
+ setIsSaving(true);
205
+ setStatusMessage(null);
206
+ try {
207
+ const res = await fetch('/api/patch', {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(payload),
211
+ });
212
+ const data = (await res.json()) as {
213
+ ok: boolean;
214
+ element?: InspectedElement;
215
+ history?: { undoAvailable: number; redoAvailable: number };
216
+ error?: string;
217
+ };
218
+ if (data.ok && data.element) {
219
+ setInspectedData(data.element);
220
+ if (data.history) setHistoryState(data.history);
221
+ setSelectedElement((prev) =>
222
+ prev
223
+ ? {
224
+ ...prev,
225
+ line: data.element!.line,
226
+ col: data.element!.col,
227
+ astPath: data.element!.astPath,
228
+ }
229
+ : null
230
+ );
231
+ setStatusMessage('Saved to .tsx');
232
+ void fetchScreens();
233
+ } else if (data.error) {
234
+ setStatusMessage(`Error: ${data.error}`);
235
+ }
236
+ } catch (err) {
237
+ setStatusMessage('Failed to write AST patch');
238
+ } finally {
239
+ setIsSaving(false);
240
+ }
241
+ },
242
+ [fetchScreens]
243
+ );
244
+
245
+ // Surgical AST Sibling Reorder handler
246
+ const handleReorderElement = useCallback(
247
+ async (payload: ReorderPayload) => {
248
+ setIsSaving(true);
249
+ setStatusMessage(null);
250
+ try {
251
+ const res = await fetch('/api/reorder', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify(payload),
255
+ });
256
+ const data = (await res.json()) as {
257
+ ok: boolean;
258
+ element?: InspectedElement;
259
+ history?: { undoAvailable: number; redoAvailable: number };
260
+ error?: string;
261
+ };
262
+ if (data.ok && data.element) {
263
+ setInspectedData(data.element);
264
+ if (data.history) setHistoryState(data.history);
265
+ setSelectedElement((prev) =>
266
+ prev
267
+ ? {
268
+ ...prev,
269
+ line: data.element!.line,
270
+ col: data.element!.col,
271
+ astPath: data.element!.astPath,
272
+ }
273
+ : null
274
+ );
275
+ setStatusMessage('Reordered in .tsx');
276
+ void fetchScreens();
277
+ } else if (data.error) {
278
+ setStatusMessage(`Error: ${data.error}`);
279
+ }
280
+ } catch {
281
+ setStatusMessage('Failed to reorder AST element');
282
+ } finally {
283
+ setIsSaving(false);
284
+ }
285
+ },
286
+ [fetchScreens]
287
+ );
288
+
289
+ // Undo & Redo handlers
290
+ const handleUndo = useCallback(async () => {
291
+ const res = await fetch('/api/undo', {
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: '{}',
295
+ });
296
+ const data = (await res.json()) as {
297
+ ok: boolean;
298
+ restoredFile: string | null;
299
+ undoAvailable: number;
300
+ redoAvailable: number;
301
+ };
302
+ if (data.ok) {
303
+ setHistoryState({
304
+ undoAvailable: data.undoAvailable,
305
+ redoAvailable: data.redoAvailable,
306
+ });
307
+ if (selectedElement) {
308
+ void inspectSelectedElement(selectedElement);
309
+ }
310
+ void fetchScreens();
311
+ setStatusMessage(data.restoredFile ? `Reverted ${data.restoredFile}` : 'Nothing to undo');
312
+ }
313
+ }, [selectedElement, inspectSelectedElement, fetchScreens]);
314
+
315
+ const handleRedo = useCallback(async () => {
316
+ const res = await fetch('/api/redo', {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: '{}',
320
+ });
321
+ const data = (await res.json()) as {
322
+ ok: boolean;
323
+ restoredFile: string | null;
324
+ undoAvailable: number;
325
+ redoAvailable: number;
326
+ };
327
+ if (data.ok) {
328
+ setHistoryState({
329
+ undoAvailable: data.undoAvailable,
330
+ redoAvailable: data.redoAvailable,
331
+ });
332
+ if (selectedElement) {
333
+ void inspectSelectedElement(selectedElement);
334
+ }
335
+ void fetchScreens();
336
+ setStatusMessage(data.restoredFile ? `Restored ${data.restoredFile}` : 'Nothing to redo');
337
+ }
338
+ }, [selectedElement, inspectSelectedElement, fetchScreens]);
339
+
340
+ // Global keyboard shortcuts (Cmd+Z, Cmd+Shift+Z, [ and ] for sidebars, Escape)
341
+ useEffect(() => {
342
+ const handleKeyDown = (e: KeyboardEvent) => {
343
+ const target = e.target as HTMLElement | null;
344
+ const isInputFocused =
345
+ target &&
346
+ (target.tagName === 'INPUT' ||
347
+ target.tagName === 'TEXTAREA' ||
348
+ target.tagName === 'SELECT');
349
+
350
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !isInputFocused) {
351
+ e.preventDefault();
352
+ if (e.shiftKey) {
353
+ void handleRedo();
354
+ } else {
355
+ void handleUndo();
356
+ }
357
+ } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === '[') {
358
+ e.preventDefault();
359
+ setIsLeftPanelOpen((prev) => !prev);
360
+ } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === ']') {
361
+ e.preventDefault();
362
+ setIsRightPanelOpen((prev) => !prev);
363
+ } else if (
364
+ !isInputFocused &&
365
+ selectedElement &&
366
+ inspectedData &&
367
+ ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)
368
+ ) {
369
+ e.preventDefault();
370
+ const step = e.shiftKey ? 8 : 1;
371
+ const isAbs = inspectedData.styles.position === 'absolute';
372
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
373
+ const prop = isAbs ? 'top' : 'marginTop';
374
+ const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
375
+ const nextVal = e.key === 'ArrowUp' ? currentVal - step : currentVal + step;
376
+ void handlePatchElement({
377
+ file: selectedElement.file,
378
+ astPath: selectedElement.astPath,
379
+ line: selectedElement.line,
380
+ styles: { [prop]: nextVal },
381
+ });
382
+ } else {
383
+ const prop = isAbs ? 'left' : 'marginLeft';
384
+ const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
385
+ const nextVal = e.key === 'ArrowLeft' ? currentVal - step : currentVal + step;
386
+ void handlePatchElement({
387
+ file: selectedElement.file,
388
+ astPath: selectedElement.astPath,
389
+ line: selectedElement.line,
390
+ styles: { [prop]: nextVal },
391
+ });
392
+ }
393
+ } else if (e.key === 'Escape') {
394
+ setSelectedElement(null);
395
+ setInspectedData(null);
396
+ }
397
+ };
398
+
399
+ window.addEventListener('keydown', handleKeyDown);
400
+ return () => window.removeEventListener('keydown', handleKeyDown);
401
+ }, [handleUndo, handleRedo, selectedElement, inspectedData, handlePatchElement]);
402
+
403
+ // Lock out whole-page browser pinch-to-zoom & route trackpad pinch to cursor-centered canvas zoom
404
+ useEffect(() => {
405
+ const viewport = canvasViewportRef.current;
406
+
407
+ const applyFocalZoom = (nextZoomRaw: number, clientX: number, clientY: number) => {
408
+ if (!viewport) return;
409
+ const rect = viewport.getBoundingClientRect();
410
+ const prevZoom = zoomRef.current;
411
+ const prevPan = panRef.current;
412
+ const nextZoom = clampZoom(nextZoomRaw);
413
+
414
+ if (nextZoom === prevZoom) return;
415
+
416
+ // Pin the world coordinate directly under the user's cursor during two-finger pinch
417
+ const cursorX = clientX - rect.left;
418
+ const cursorY = clientY - rect.top;
419
+ const worldX = (cursorX - prevPan.x) / prevZoom;
420
+ const worldY = (cursorY - prevPan.y) / prevZoom;
421
+
422
+ const nextPan = {
423
+ x: cursorX - worldX * nextZoom,
424
+ y: cursorY - worldY * nextZoom,
425
+ };
426
+
427
+ setIsCameraAnimating(false);
428
+ zoomRef.current = nextZoom;
429
+ panRef.current = nextPan;
430
+ setZoom(nextZoom);
431
+ setPan(nextPan);
432
+ };
433
+
434
+ // 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
435
+ const handleCanvasWheel = (e: WheelEvent) => {
436
+ e.preventDefault();
437
+ e.stopPropagation();
438
+
439
+ if (e.ctrlKey || e.metaKey) {
440
+ // Trackpad two-finger pinch or Cmd/Ctrl + wheel
441
+ const zoomFactor = Math.exp(-e.deltaY * 0.0085);
442
+ applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
443
+ } else {
444
+ setIsCameraAnimating(false);
445
+ const nextPan = {
446
+ x: panRef.current.x - e.deltaX,
447
+ y: panRef.current.y - e.deltaY,
448
+ };
449
+ panRef.current = nextPan;
450
+ setPan(nextPan);
451
+ }
452
+ };
453
+
454
+ // 2. Document-level non-passive guard so pinching over the top bar or sidebars NEVER zooms the browser page
455
+ const handleDocumentWheelGuard = (e: WheelEvent) => {
456
+ if (e.ctrlKey || e.metaKey) {
457
+ e.preventDefault();
458
+ }
459
+ };
460
+
461
+ // 3. WebKit/Safari proprietary trackpad gesture guard (gesturestart / gesturechange / gestureend)
462
+ let gestureStartZoom = zoomRef.current;
463
+ const handleGestureStart = (e: Event) => {
464
+ e.preventDefault();
465
+ gestureStartZoom = zoomRef.current;
466
+ };
467
+ const handleGestureChange = (e: Event) => {
468
+ e.preventDefault();
469
+ const gestureEvent = e as unknown as {
470
+ scale?: number;
471
+ clientX?: number;
472
+ clientY?: number;
473
+ };
474
+ if (typeof gestureEvent.scale === 'number' && viewport) {
475
+ const rect = viewport.getBoundingClientRect();
476
+ const cx = gestureEvent.clientX ?? rect.left + rect.width / 2;
477
+ const cy = gestureEvent.clientY ?? rect.top + rect.height / 2;
478
+ applyFocalZoom(gestureStartZoom * gestureEvent.scale, cx, cy);
479
+ }
480
+ };
481
+ const handleGestureEnd = (e: Event) => {
482
+ e.preventDefault();
483
+ };
484
+
485
+ viewport?.addEventListener('wheel', handleCanvasWheel, { passive: false });
486
+ document.addEventListener('wheel', handleDocumentWheelGuard, { passive: false });
487
+ document.addEventListener('gesturestart', handleGestureStart, { passive: false });
488
+ document.addEventListener('gesturechange', handleGestureChange, { passive: false });
489
+ document.addEventListener('gestureend', handleGestureEnd, { passive: false });
490
+
491
+ return () => {
492
+ viewport?.removeEventListener('wheel', handleCanvasWheel);
493
+ document.removeEventListener('wheel', handleDocumentWheelGuard);
494
+ document.removeEventListener('gesturestart', handleGestureStart);
495
+ document.removeEventListener('gesturechange', handleGestureChange);
496
+ document.removeEventListener('gestureend', handleGestureEnd);
497
+ };
498
+ }, []);
499
+
500
+ // Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
501
+ const focusOnScreen = useCallback(
502
+ (screenId: string) => {
503
+ const pos = layout[screenId];
504
+ const viewport = canvasViewportRef.current;
505
+ if (!pos || !viewport) return;
506
+
507
+ const rect = viewport.getBoundingClientRect();
508
+ const targetZoom = Math.min(Math.max(zoom, 0.72), 0.95);
509
+ const nextPanX = rect.width / 2 - (pos.x + pos.width / 2) * targetZoom;
510
+ const nextPanY = rect.height / 2 - (pos.y + pos.height / 2) * targetZoom;
511
+
512
+ triggerCameraTransition();
513
+ setZoom(targetZoom);
514
+ setPan({ x: Math.round(nextPanX), y: Math.round(nextPanY) });
515
+ setActiveScreenId(screenId);
516
+ setHighlightedScreenId(screenId);
517
+ window.setTimeout(() => setHighlightedScreenId(null), 1600);
518
+ },
519
+ [layout, zoom, triggerCameraTransition]
520
+ );
521
+
522
+ // Fit all screens in viewport
523
+ const handleFitAllScreens = useCallback(() => {
524
+ const viewport = canvasViewportRef.current;
525
+ if (!viewport || screens.length === 0) return;
526
+ const rect = viewport.getBoundingClientRect();
527
+
528
+ const positions = Object.values(layout);
529
+ if (positions.length === 0) return;
530
+
531
+ const minX = Math.min(...positions.map((p) => p.x));
532
+ const minY = Math.min(...positions.map((p) => p.y));
533
+ const maxX = Math.max(...positions.map((p) => p.x + p.width));
534
+ const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
535
+
536
+ const contentWidth = maxX - minX + 140;
537
+ const contentHeight = maxY - minY + 140;
538
+
539
+ const scaleX = rect.width / contentWidth;
540
+ const scaleY = rect.height / contentHeight;
541
+ const nextZoom = Number(Math.min(Math.max(Math.min(scaleX, scaleY), 0.28), 1.0).toFixed(2));
542
+
543
+ const centerX = (minX + maxX) / 2;
544
+ const centerY = (minY + maxY) / 2;
545
+
546
+ triggerCameraTransition();
547
+ setZoom(nextZoom);
548
+ setPan({
549
+ x: Math.round(rect.width / 2 - centerX * nextZoom),
550
+ y: Math.round(rect.height / 2 - centerY * nextZoom),
551
+ });
552
+ }, [layout, screens.length, triggerCameraTransition]);
553
+
554
+ // Reset layout to clean horizontal grid
555
+ const handleResetGridLayout = useCallback(() => {
556
+ const reset = buildDefaultLayout(screens);
557
+ triggerCameraTransition();
558
+ setLayout(reset);
559
+ void fetch('/api/layout', {
560
+ method: 'POST',
561
+ headers: { 'Content-Type': 'application/json' },
562
+ body: JSON.stringify(reset),
563
+ });
564
+ }, [screens, triggerCameraTransition]);
565
+
566
+ // Move single artboard
567
+ const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
568
+ setLayout((prev) => {
569
+ const current = prev[screenId] ?? {
570
+ x: nextX,
571
+ y: nextY,
572
+ width: DEFAULT_WIDTH,
573
+ height: DEFAULT_HEIGHT,
574
+ };
575
+ const updated = {
576
+ ...prev,
577
+ [screenId]: { ...current, x: nextX, y: nextY },
578
+ };
579
+ return updated;
580
+ });
581
+ }, []);
582
+
583
+ // Handle interactive navigation inside a React Native screen
584
+ const handleNavigateFlow = useCallback(
585
+ (_fromScreen: string, toScreen: string) => {
586
+ focusOnScreen(toScreen);
587
+ },
588
+ [focusOnScreen]
589
+ );
590
+
591
+ // Canvas background pointer pan (0ms direct manipulation override)
592
+ const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
593
+ if (e.button !== 0 && e.button !== 1) return;
594
+ setIsCameraAnimating(false);
595
+ setIsPanning(true);
596
+
597
+ const startX = e.clientX;
598
+ const startY = e.clientY;
599
+ const startPanX = pan.x;
600
+ const startPanY = pan.y;
601
+
602
+ const handleMove = (moveEvent: PointerEvent) => {
603
+ const nextPan = {
604
+ x: startPanX + (moveEvent.clientX - startX),
605
+ y: startPanY + (moveEvent.clientY - startY),
606
+ };
607
+ panRef.current = nextPan;
608
+ setPan(nextPan);
609
+ };
610
+
611
+ const handleUp = () => {
612
+ setIsPanning(false);
613
+ window.removeEventListener('pointermove', handleMove);
614
+ window.removeEventListener('pointerup', handleUp);
615
+ };
616
+
617
+ window.addEventListener('pointermove', handleMove);
618
+ window.addEventListener('pointerup', handleUp);
619
+ };
620
+
621
+ return (
622
+ <div
623
+ className="zelune-studio-root"
624
+ data-density="spacious"
625
+ style={{
626
+ display: 'flex',
627
+ flexDirection: 'column',
628
+ width: '100vw',
629
+ height: '100vh',
630
+ backgroundColor: 'var(--studio-canvas-bg)',
631
+ color: 'var(--studio-text-primary)',
632
+ overflow: 'hidden',
633
+ }}
634
+ >
635
+ {/* Top Studio Bar */}
636
+ <header
637
+ style={{
638
+ height: 52,
639
+ backgroundColor: 'var(--studio-surface-1)',
640
+ borderBottom: '1px solid var(--studio-border-hairline)',
641
+ display: 'flex',
642
+ alignItems: 'center',
643
+ justifyContent: 'space-between',
644
+ padding: '0 14px',
645
+ zIndex: 40,
646
+ }}
647
+ >
648
+ {/* Left: Sidebar Toggle + Architectural Brand Identity */}
649
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
650
+ <button
651
+ type="button"
652
+ className="zelune-interactive"
653
+ onClick={() => setIsLeftPanelOpen((prev) => !prev)}
654
+ title={`${isLeftPanelOpen ? 'Collapse' : 'Expand'} Screens Sidebar ([)`}
655
+ style={{
656
+ display: 'inline-flex',
657
+ alignItems: 'center',
658
+ justifyContent: 'center',
659
+ width: 30,
660
+ height: 30,
661
+ borderRadius: 7,
662
+ border: `1px solid ${
663
+ isLeftPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
664
+ }`,
665
+ backgroundColor: isLeftPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
666
+ color: isLeftPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
667
+ cursor: 'pointer',
668
+ }}
669
+ >
670
+ {isLeftPanelOpen ? <PanelLeftClose size={14} /> : <PanelLeftOpen size={14} />}
671
+ </button>
672
+
673
+ <div
674
+ style={{
675
+ width: 30,
676
+ height: 30,
677
+ borderRadius: 7,
678
+ backgroundColor: 'var(--studio-surface-2)',
679
+ border: '1px solid rgba(56, 189, 248, 0.35)',
680
+ boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
681
+ display: 'flex',
682
+ alignItems: 'center',
683
+ justifyContent: 'center',
684
+ fontFamily: 'var(--font-mono)',
685
+ fontWeight: 700,
686
+ fontSize: 12,
687
+ letterSpacing: '-0.03em',
688
+ color: '#38BDF8',
689
+ }}
690
+ >
691
+ ZL
692
+ </div>
693
+ <div>
694
+ <div style={{ fontSize: 13.5, fontWeight: 700, letterSpacing: '-0.02em', color: '#F8FAFC' }}>
695
+ Zelune
696
+ </div>
697
+ <div
698
+ style={{
699
+ fontSize: 11,
700
+ color: 'var(--studio-text-secondary)',
701
+ fontFamily: 'var(--font-mono)',
702
+ marginTop: 1,
703
+ }}
704
+ >
705
+ {projectName}
706
+ </div>
707
+ </div>
708
+ </div>
709
+
710
+ {/* Center: Icon-First Mode Switcher & Flow Toggle (Reveals Label on Hover) */}
711
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
712
+ <div
713
+ style={{
714
+ display: 'flex',
715
+ alignItems: 'center',
716
+ gap: 2,
717
+ backgroundColor: 'var(--studio-canvas-bg)',
718
+ padding: 3,
719
+ borderRadius: 8,
720
+ border: '1px solid var(--studio-border-subtle)',
721
+ }}
722
+ >
723
+ <button
724
+ type="button"
725
+ className="zelune-interactive zelune-icon-reveal-btn"
726
+ onClick={() => setMode('inspect')}
727
+ title="Inspect & Edit"
728
+ aria-label="Inspect & Edit"
729
+ style={{
730
+ padding: '6px 9px',
731
+ borderRadius: 5,
732
+ border: mode === 'inspect' ? '1px solid rgba(56, 189, 248, 0.35)' : '1px solid transparent',
733
+ backgroundColor: mode === 'inspect' ? 'var(--studio-surface-3)' : 'transparent',
734
+ color: mode === 'inspect' ? '#38BDF8' : 'var(--studio-text-secondary)',
735
+ fontSize: 12,
736
+ fontWeight: 600,
737
+ cursor: 'pointer',
738
+ }}
739
+ >
740
+ <MousePointer size={14} style={{ flexShrink: 0 }} />
741
+ <span className="zelune-reveal-label">Inspect &amp; Edit</span>
742
+ </button>
743
+
744
+ <button
745
+ type="button"
746
+ className="zelune-interactive zelune-icon-reveal-btn"
747
+ onClick={() => setMode('interact')}
748
+ title="Interact & Navigate"
749
+ aria-label="Interact & Navigate"
750
+ style={{
751
+ padding: '6px 9px',
752
+ borderRadius: 5,
753
+ border: mode === 'interact' ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid transparent',
754
+ backgroundColor: mode === 'interact' ? 'rgba(6, 78, 59, 0.45)' : 'transparent',
755
+ color: mode === 'interact' ? '#34D399' : 'var(--studio-text-secondary)',
756
+ fontSize: 12,
757
+ fontWeight: 600,
758
+ cursor: 'pointer',
759
+ }}
760
+ >
761
+ <Play size={14} style={{ flexShrink: 0 }} />
762
+ <span className="zelune-reveal-label">Interact &amp; Navigate</span>
763
+ </button>
764
+ </div>
765
+
766
+ <button
767
+ type="button"
768
+ className="zelune-interactive zelune-icon-reveal-btn"
769
+ onClick={() => setShowFlows((prev) => !prev)}
770
+ title="Toggle Flow Graph"
771
+ aria-label="Toggle Flow Graph"
772
+ style={{
773
+ padding: '7px 9px',
774
+ borderRadius: 7,
775
+ border: `1px solid ${showFlows ? 'rgba(99, 102, 241, 0.45)' : 'var(--studio-border-subtle)'}`,
776
+ backgroundColor: showFlows ? 'rgba(30, 27, 75, 0.55)' : 'var(--studio-surface-2)',
777
+ color: showFlows ? '#C7D2FE' : 'var(--studio-text-secondary)',
778
+ fontSize: 12,
779
+ fontWeight: 600,
780
+ cursor: 'pointer',
781
+ }}
782
+ >
783
+ <GitCommit size={14} style={{ flexShrink: 0 }} />
784
+ <span className="zelune-reveal-label">Flow Graph</span>
785
+ </button>
786
+ </div>
787
+
788
+ {/* Right: Undo/Redo, Viewport Controls & Right Inspector Toggle */}
789
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
790
+ <button
791
+ type="button"
792
+ className="zelune-interactive tabular-nums"
793
+ onClick={handleUndo}
794
+ disabled={historyState.undoAvailable === 0}
795
+ title="Undo AST Patch (Cmd+Z)"
796
+ style={{
797
+ display: 'inline-flex',
798
+ alignItems: 'center',
799
+ gap: 4,
800
+ padding: '6px 9px',
801
+ borderRadius: 6,
802
+ border: '1px solid var(--studio-border-subtle)',
803
+ backgroundColor: 'var(--studio-surface-2)',
804
+ color: historyState.undoAvailable > 0 ? '#E2E8F0' : '#475569',
805
+ fontSize: 11.5,
806
+ fontFamily: 'var(--font-mono)',
807
+ cursor: historyState.undoAvailable > 0 ? 'pointer' : 'not-allowed',
808
+ }}
809
+ >
810
+ <Undo2 size={13} />
811
+ <span>{historyState.undoAvailable}</span>
812
+ </button>
813
+
814
+ <button
815
+ type="button"
816
+ className="zelune-interactive"
817
+ onClick={handleRedo}
818
+ disabled={historyState.redoAvailable === 0}
819
+ title="Redo AST Patch (Cmd+Shift+Z)"
820
+ style={{
821
+ display: 'inline-flex',
822
+ alignItems: 'center',
823
+ gap: 4,
824
+ padding: '6px 9px',
825
+ borderRadius: 6,
826
+ border: '1px solid var(--studio-border-subtle)',
827
+ backgroundColor: 'var(--studio-surface-2)',
828
+ color: historyState.redoAvailable > 0 ? '#E2E8F0' : '#475569',
829
+ fontSize: 11.5,
830
+ cursor: historyState.redoAvailable > 0 ? 'pointer' : 'not-allowed',
831
+ }}
832
+ >
833
+ <Redo2 size={13} />
834
+ </button>
835
+
836
+ <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
837
+
838
+ <button
839
+ type="button"
840
+ className="zelune-interactive"
841
+ onClick={() => {
842
+ triggerCameraTransition();
843
+ setZoom((z) => clampZoom(z - 0.1));
844
+ }}
845
+ title="Zoom Out"
846
+ style={{
847
+ padding: 6,
848
+ borderRadius: 6,
849
+ border: '1px solid var(--studio-border-subtle)',
850
+ backgroundColor: 'var(--studio-surface-2)',
851
+ color: '#CBD5E1',
852
+ cursor: 'pointer',
853
+ }}
854
+ >
855
+ <ZoomOut size={13} />
856
+ </button>
857
+
858
+ <span
859
+ className="tabular-nums"
860
+ style={{
861
+ minWidth: 46,
862
+ textAlign: 'center',
863
+ fontSize: 11.5,
864
+ fontFamily: 'var(--font-mono)',
865
+ color: '#CBD5E1',
866
+ }}
867
+ >
868
+ {Math.round(zoom * 100)}%
869
+ </span>
870
+
871
+ <button
872
+ type="button"
873
+ className="zelune-interactive"
874
+ onClick={() => {
875
+ triggerCameraTransition();
876
+ setZoom((z) => clampZoom(z + 0.1));
877
+ }}
878
+ title="Zoom In"
879
+ style={{
880
+ padding: 6,
881
+ borderRadius: 6,
882
+ border: '1px solid var(--studio-border-subtle)',
883
+ backgroundColor: 'var(--studio-surface-2)',
884
+ color: '#CBD5E1',
885
+ cursor: 'pointer',
886
+ }}
887
+ >
888
+ <ZoomIn size={13} />
889
+ </button>
890
+
891
+ <button
892
+ type="button"
893
+ className="zelune-interactive zelune-icon-reveal-btn"
894
+ onClick={handleFitAllScreens}
895
+ title="Fit All Screens in Viewport"
896
+ aria-label="Fit All Screens in Viewport"
897
+ style={{
898
+ padding: '6px 8px',
899
+ borderRadius: 6,
900
+ border: '1px solid var(--studio-border-subtle)',
901
+ backgroundColor: 'var(--studio-surface-2)',
902
+ color: '#CBD5E1',
903
+ fontSize: 11.5,
904
+ fontWeight: 500,
905
+ cursor: 'pointer',
906
+ }}
907
+ >
908
+ <Maximize size={13} style={{ flexShrink: 0 }} />
909
+ <span className="zelune-reveal-label">Fit</span>
910
+ </button>
911
+
912
+ <button
913
+ type="button"
914
+ className="zelune-interactive zelune-icon-reveal-btn"
915
+ onClick={handleResetGridLayout}
916
+ title="Auto-Arrange Clean Artboard Grid"
917
+ aria-label="Auto-Arrange Clean Artboard Grid"
918
+ style={{
919
+ padding: '6px 8px',
920
+ borderRadius: 6,
921
+ border: '1px solid var(--studio-border-subtle)',
922
+ backgroundColor: 'var(--studio-surface-2)',
923
+ color: '#CBD5E1',
924
+ fontSize: 11.5,
925
+ fontWeight: 500,
926
+ cursor: 'pointer',
927
+ }}
928
+ >
929
+ <LayoutGrid size={13} style={{ flexShrink: 0 }} />
930
+ <span className="zelune-reveal-label">Grid</span>
931
+ </button>
932
+
933
+ <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
934
+
935
+ <button
936
+ type="button"
937
+ className="zelune-interactive"
938
+ onClick={() => setIsRightPanelOpen((prev) => !prev)}
939
+ title={`${isRightPanelOpen ? 'Collapse' : 'Expand'} AST Inspector Panel (])`}
940
+ style={{
941
+ display: 'inline-flex',
942
+ alignItems: 'center',
943
+ justifyContent: 'center',
944
+ width: 30,
945
+ height: 30,
946
+ borderRadius: 7,
947
+ border: `1px solid ${
948
+ isRightPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
949
+ }`,
950
+ backgroundColor: isRightPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
951
+ color: isRightPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
952
+ cursor: 'pointer',
953
+ }}
954
+ >
955
+ {isRightPanelOpen ? <PanelRightClose size={14} /> : <PanelRightOpen size={14} />}
956
+ </button>
957
+ </div>
958
+ </header>
959
+
960
+ {/* Main Studio Body: Collapsible Left Screen Rail + Infinite Canvas + Collapsible Right AST Inspector */}
961
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
962
+ {/* Left Sidebar: Screen Artboard Navigator */}
963
+ <aside
964
+ aria-hidden={!isLeftPanelOpen}
965
+ className="zelune-collapsible-panel"
966
+ style={{
967
+ width: isLeftPanelOpen ? 224 : 0,
968
+ opacity: isLeftPanelOpen ? 1 : 0,
969
+ pointerEvents: isLeftPanelOpen ? 'auto' : 'none',
970
+ backgroundColor: 'var(--studio-surface-1)',
971
+ borderRight: isLeftPanelOpen
972
+ ? '1px solid var(--studio-border-hairline)'
973
+ : '0px solid transparent',
974
+ display: 'flex',
975
+ flexDirection: 'column',
976
+ zIndex: 30,
977
+ }}
978
+ >
979
+ <div
980
+ className="zelune-scroller"
981
+ style={{
982
+ width: 224,
983
+ minWidth: 224,
984
+ height: '100%',
985
+ display: 'flex',
986
+ flexDirection: 'column',
987
+ justifyContent: 'space-between',
988
+ padding: 12,
989
+ overflowY: 'auto',
990
+ }}
991
+ >
992
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
993
+ <div
994
+ className="tabular-nums"
995
+ style={{
996
+ display: 'flex',
997
+ alignItems: 'center',
998
+ justifyContent: 'space-between',
999
+ padding: '4px 6px 6px',
1000
+ fontSize: 10.5,
1001
+ fontFamily: 'var(--font-mono)',
1002
+ fontWeight: 600,
1003
+ color: 'var(--studio-text-muted)',
1004
+ letterSpacing: '0.06em',
1005
+ }}
1006
+ >
1007
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1008
+ <Layers size={12} color="#38BDF8" />
1009
+ <span>SCREENS ({screens.length})</span>
1010
+ </span>
1011
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
1012
+ <button
1013
+ type="button"
1014
+ className="zelune-interactive"
1015
+ onClick={() => void fetchScreens()}
1016
+ title="Refresh Screen Discovery"
1017
+ style={{
1018
+ background: 'transparent',
1019
+ border: '1px solid transparent',
1020
+ borderRadius: 4,
1021
+ color: 'var(--studio-text-muted)',
1022
+ cursor: 'pointer',
1023
+ padding: 3,
1024
+ display: 'inline-flex',
1025
+ alignItems: 'center',
1026
+ }}
1027
+ >
1028
+ <RefreshCw size={11} />
1029
+ </button>
1030
+ <button
1031
+ type="button"
1032
+ className="zelune-interactive"
1033
+ onClick={() => setIsLeftPanelOpen(false)}
1034
+ title="Collapse Screens Sidebar ([)"
1035
+ style={{
1036
+ background: 'transparent',
1037
+ border: '1px solid transparent',
1038
+ borderRadius: 4,
1039
+ color: 'var(--studio-text-muted)',
1040
+ cursor: 'pointer',
1041
+ padding: 3,
1042
+ display: 'inline-flex',
1043
+ alignItems: 'center',
1044
+ }}
1045
+ >
1046
+ <PanelLeftClose size={12} />
1047
+ </button>
1048
+ </div>
1049
+ </div>
1050
+
1051
+ {screens.map((scr) => {
1052
+ const isSelected = activeScreenId === scr.id;
1053
+ return (
1054
+ <button
1055
+ key={scr.id}
1056
+ type="button"
1057
+ className="zelune-interactive zelune-rail-item"
1058
+ onClick={() => focusOnScreen(scr.id)}
1059
+ style={{
1060
+ display: 'flex',
1061
+ alignItems: 'center',
1062
+ justifyContent: 'space-between',
1063
+ width: '100%',
1064
+ paddingLeft: 10,
1065
+ paddingRight: 10,
1066
+ borderRadius: 7,
1067
+ border: `1px solid ${
1068
+ isSelected ? 'rgba(56, 189, 248, 0.35)' : 'transparent'
1069
+ }`,
1070
+ backgroundColor: isSelected ? 'var(--studio-surface-2)' : 'transparent',
1071
+ color: isSelected ? '#F8FAFC' : 'var(--studio-text-secondary)',
1072
+ fontSize: 12.5,
1073
+ fontWeight: isSelected ? 600 : 500,
1074
+ cursor: 'pointer',
1075
+ textAlign: 'left',
1076
+ }}
1077
+ >
1078
+ <span>{scr.id}</span>
1079
+ <span
1080
+ className="tabular-nums"
1081
+ style={{
1082
+ fontSize: 10,
1083
+ fontFamily: 'var(--font-mono)',
1084
+ color: isSelected ? '#38BDF8' : 'var(--studio-text-muted)',
1085
+ }}
1086
+ >
1087
+ {scr.outgoingFlows.length}→
1088
+ </span>
1089
+ </button>
1090
+ );
1091
+ })}
1092
+ </div>
1093
+
1094
+ <div
1095
+ style={{
1096
+ backgroundColor: 'var(--studio-surface-2)',
1097
+ border: '1px solid var(--studio-border-hairline)',
1098
+ borderRadius: 9,
1099
+ padding: 11,
1100
+ fontSize: 11,
1101
+ color: 'var(--studio-text-secondary)',
1102
+ lineHeight: 1.55,
1103
+ }}
1104
+ >
1105
+ <div
1106
+ style={{
1107
+ fontFamily: 'var(--font-mono)',
1108
+ fontSize: 10,
1109
+ fontWeight: 600,
1110
+ letterSpacing: '0.05em',
1111
+ color: '#E2E8F0',
1112
+ marginBottom: 4,
1113
+ }}
1114
+ >
1115
+ STUDIO ERGONOMICS
1116
+ </div>
1117
+ <div>• Two-finger scroll to pan</div>
1118
+ <div>• Two-finger pinch to zoom</div>
1119
+ <div>• Press [ or ] to toggle panels</div>
1120
+ <div>• Click any node to patch AST</div>
1121
+ </div>
1122
+ </div>
1123
+ </aside>
1124
+
1125
+ {/* Center: Infinite Zoom/Pan Canvas */}
1126
+ <main
1127
+ ref={canvasViewportRef}
1128
+ onPointerDown={handleCanvasPointerDown}
1129
+ className="infinite-canvas-surface"
1130
+ style={{
1131
+ flex: 1,
1132
+ position: 'relative',
1133
+ overflow: 'hidden',
1134
+ cursor: isPanning ? 'grabbing' : 'grab',
1135
+ }}
1136
+ >
1137
+ {/* Floating Edge Restore Pill for Collapsed Left Sidebar */}
1138
+ {!isLeftPanelOpen && (
1139
+ <button
1140
+ type="button"
1141
+ className="zelune-interactive tabular-nums"
1142
+ onClick={(e) => {
1143
+ e.stopPropagation();
1144
+ setIsLeftPanelOpen(true);
1145
+ }}
1146
+ onPointerDown={(e) => e.stopPropagation()}
1147
+ title="Expand Screens Navigator ([)"
1148
+ style={{
1149
+ position: 'absolute',
1150
+ top: 14,
1151
+ left: 14,
1152
+ zIndex: 35,
1153
+ display: 'inline-flex',
1154
+ alignItems: 'center',
1155
+ gap: 7,
1156
+ padding: '7px 11px',
1157
+ borderRadius: 8,
1158
+ backgroundColor: 'var(--studio-surface-1)',
1159
+ border: '1px solid var(--studio-border-subtle)',
1160
+ color: '#F1F5F9',
1161
+ fontSize: 11.5,
1162
+ fontFamily: 'var(--font-mono)',
1163
+ fontWeight: 600,
1164
+ cursor: 'pointer',
1165
+ boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.65)',
1166
+ }}
1167
+ >
1168
+ <PanelLeftOpen size={13} color="#38BDF8" />
1169
+ <span>Screens ({screens.length})</span>
1170
+ </button>
1171
+ )}
1172
+
1173
+ {/* Floating Edge Restore Pill for Collapsed Right Inspector */}
1174
+ {!isRightPanelOpen && (
1175
+ <button
1176
+ type="button"
1177
+ className="zelune-interactive tabular-nums"
1178
+ onClick={(e) => {
1179
+ e.stopPropagation();
1180
+ setIsRightPanelOpen(true);
1181
+ }}
1182
+ onPointerDown={(e) => e.stopPropagation()}
1183
+ title="Expand AST Visual Inspector (])"
1184
+ style={{
1185
+ position: 'absolute',
1186
+ top: 14,
1187
+ right: 14,
1188
+ zIndex: 35,
1189
+ display: 'inline-flex',
1190
+ alignItems: 'center',
1191
+ gap: 7,
1192
+ padding: '7px 11px',
1193
+ borderRadius: 8,
1194
+ backgroundColor: 'var(--studio-surface-1)',
1195
+ border: '1px solid var(--studio-border-subtle)',
1196
+ color: '#F1F5F9',
1197
+ fontSize: 11.5,
1198
+ fontFamily: 'var(--font-mono)',
1199
+ fontWeight: 600,
1200
+ cursor: 'pointer',
1201
+ boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.65)',
1202
+ }}
1203
+ >
1204
+ <Sliders size={13} color="#38BDF8" />
1205
+ <span>
1206
+ {inspectedData ? `<${inspectedData.tagName}> L${inspectedData.line}` : 'AST Inspector'}
1207
+ </span>
1208
+ <PanelRightOpen size={13} color="#94A3B8" />
1209
+ </button>
1210
+ )}
1211
+
1212
+ {/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
1213
+ <div
1214
+ style={{
1215
+ position: 'absolute',
1216
+ top: 0,
1217
+ left: 0,
1218
+ transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
1219
+ transformOrigin: '0 0',
1220
+ willChange: 'transform',
1221
+ transition: isCameraAnimating
1222
+ ? 'transform var(--duration-camera) var(--ease-out-expo)'
1223
+ : 'none',
1224
+ }}
1225
+ >
1226
+ {showFlows && (
1227
+ <FlowArrowsLayer
1228
+ screens={screens}
1229
+ layout={layout}
1230
+ activeScreenId={activeScreenId}
1231
+ highlightedScreenId={highlightedScreenId}
1232
+ onSelectFlow={(edge: NavigationEdge) => focusOnScreen(edge.toScreen)}
1233
+ />
1234
+ )}
1235
+
1236
+ {screens.map((screen) => {
1237
+ const pos = layout[screen.id] ?? {
1238
+ x: INITIAL_OFFSET_X,
1239
+ y: INITIAL_OFFSET_Y,
1240
+ width: DEFAULT_WIDTH,
1241
+ height: DEFAULT_HEIGHT,
1242
+ };
1243
+ const ScreenComponent = SCREEN_COMPONENTS[screen.id];
1244
+
1245
+ return (
1246
+ <ScreenArtboard
1247
+ key={screen.id}
1248
+ screen={screen}
1249
+ ScreenComponent={ScreenComponent}
1250
+ position={pos}
1251
+ zoom={zoom}
1252
+ mode={mode}
1253
+ isActiveScreen={activeScreenId === screen.id}
1254
+ isHighlightedScreen={highlightedScreenId === screen.id}
1255
+ selectedElement={selectedElement}
1256
+ inspectedElement={inspectedData}
1257
+ onSelectElement={handleSelectElement}
1258
+ onSelectScreen={setActiveScreenId}
1259
+ onFocusScreen={focusOnScreen}
1260
+ onMoveArtboard={handleMoveArtboard}
1261
+ onNavigateFlow={handleNavigateFlow}
1262
+ onPatchElement={handlePatchElement}
1263
+ onReorderElement={handleReorderElement}
1264
+ />
1265
+ );
1266
+ })}
1267
+ </div>
1268
+ </main>
1269
+
1270
+ {/* Right: AST Visual Inspector Panel */}
1271
+ <InspectorPanel
1272
+ inspected={inspectedData}
1273
+ isOpen={isRightPanelOpen}
1274
+ isLoading={isInspecting}
1275
+ isSaving={isSaving}
1276
+ statusMessage={statusMessage}
1277
+ onPatch={handlePatchElement}
1278
+ onClearSelection={() => {
1279
+ setSelectedElement(null);
1280
+ setInspectedData(null);
1281
+ }}
1282
+ onCollapse={() => setIsRightPanelOpen(false)}
1283
+ onJumpToScreen={focusOnScreen}
1284
+ />
1285
+ </div>
1286
+ </div>
1287
+ );
1288
+ };