zelune 0.0.0-stage → 1.4.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.
@@ -0,0 +1,1203 @@
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 } 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
+ // Undo & Redo handlers
246
+ const handleUndo = useCallback(async () => {
247
+ const res = await fetch('/api/undo', { method: 'POST' });
248
+ const data = (await res.json()) as {
249
+ ok: boolean;
250
+ restoredFile: string | null;
251
+ undoAvailable: number;
252
+ redoAvailable: number;
253
+ };
254
+ if (data.ok) {
255
+ setHistoryState({
256
+ undoAvailable: data.undoAvailable,
257
+ redoAvailable: data.redoAvailable,
258
+ });
259
+ if (selectedElement) {
260
+ void inspectSelectedElement(selectedElement);
261
+ }
262
+ void fetchScreens();
263
+ setStatusMessage(data.restoredFile ? `Reverted ${data.restoredFile}` : 'Nothing to undo');
264
+ }
265
+ }, [selectedElement, inspectSelectedElement, fetchScreens]);
266
+
267
+ const handleRedo = useCallback(async () => {
268
+ const res = await fetch('/api/redo', { method: 'POST' });
269
+ const data = (await res.json()) as {
270
+ ok: boolean;
271
+ restoredFile: string | null;
272
+ undoAvailable: number;
273
+ redoAvailable: number;
274
+ };
275
+ if (data.ok) {
276
+ setHistoryState({
277
+ undoAvailable: data.undoAvailable,
278
+ redoAvailable: data.redoAvailable,
279
+ });
280
+ if (selectedElement) {
281
+ void inspectSelectedElement(selectedElement);
282
+ }
283
+ void fetchScreens();
284
+ setStatusMessage(data.restoredFile ? `Restored ${data.restoredFile}` : 'Nothing to redo');
285
+ }
286
+ }, [selectedElement, inspectSelectedElement, fetchScreens]);
287
+
288
+ // Global keyboard shortcuts (Cmd+Z, Cmd+Shift+Z, [ and ] for sidebars, Escape)
289
+ useEffect(() => {
290
+ const handleKeyDown = (e: KeyboardEvent) => {
291
+ const target = e.target as HTMLElement | null;
292
+ const isInputFocused =
293
+ target &&
294
+ (target.tagName === 'INPUT' ||
295
+ target.tagName === 'TEXTAREA' ||
296
+ target.tagName === 'SELECT');
297
+
298
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !isInputFocused) {
299
+ e.preventDefault();
300
+ if (e.shiftKey) {
301
+ void handleRedo();
302
+ } else {
303
+ void handleUndo();
304
+ }
305
+ } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === '[') {
306
+ e.preventDefault();
307
+ setIsLeftPanelOpen((prev) => !prev);
308
+ } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === ']') {
309
+ e.preventDefault();
310
+ setIsRightPanelOpen((prev) => !prev);
311
+ } else if (e.key === 'Escape') {
312
+ setSelectedElement(null);
313
+ setInspectedData(null);
314
+ }
315
+ };
316
+
317
+ window.addEventListener('keydown', handleKeyDown);
318
+ return () => window.removeEventListener('keydown', handleKeyDown);
319
+ }, [handleUndo, handleRedo]);
320
+
321
+ // Lock out whole-page browser pinch-to-zoom & route trackpad pinch to cursor-centered canvas zoom
322
+ useEffect(() => {
323
+ const viewport = canvasViewportRef.current;
324
+
325
+ const applyFocalZoom = (nextZoomRaw: number, clientX: number, clientY: number) => {
326
+ if (!viewport) return;
327
+ const rect = viewport.getBoundingClientRect();
328
+ const prevZoom = zoomRef.current;
329
+ const prevPan = panRef.current;
330
+ const nextZoom = clampZoom(nextZoomRaw);
331
+
332
+ if (nextZoom === prevZoom) return;
333
+
334
+ // Pin the world coordinate directly under the user's cursor during two-finger pinch
335
+ const cursorX = clientX - rect.left;
336
+ const cursorY = clientY - rect.top;
337
+ const worldX = (cursorX - prevPan.x) / prevZoom;
338
+ const worldY = (cursorY - prevPan.y) / prevZoom;
339
+
340
+ const nextPan = {
341
+ x: cursorX - worldX * nextZoom,
342
+ y: cursorY - worldY * nextZoom,
343
+ };
344
+
345
+ setIsCameraAnimating(false);
346
+ zoomRef.current = nextZoom;
347
+ panRef.current = nextPan;
348
+ setZoom(nextZoom);
349
+ setPan(nextPan);
350
+ };
351
+
352
+ // 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
353
+ const handleCanvasWheel = (e: WheelEvent) => {
354
+ e.preventDefault();
355
+ e.stopPropagation();
356
+
357
+ if (e.ctrlKey || e.metaKey) {
358
+ // Trackpad two-finger pinch or Cmd/Ctrl + wheel
359
+ const zoomFactor = Math.exp(-e.deltaY * 0.0085);
360
+ applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
361
+ } else {
362
+ setIsCameraAnimating(false);
363
+ const nextPan = {
364
+ x: panRef.current.x - e.deltaX,
365
+ y: panRef.current.y - e.deltaY,
366
+ };
367
+ panRef.current = nextPan;
368
+ setPan(nextPan);
369
+ }
370
+ };
371
+
372
+ // 2. Document-level non-passive guard so pinching over the top bar or sidebars NEVER zooms the browser page
373
+ const handleDocumentWheelGuard = (e: WheelEvent) => {
374
+ if (e.ctrlKey || e.metaKey) {
375
+ e.preventDefault();
376
+ }
377
+ };
378
+
379
+ // 3. WebKit/Safari proprietary trackpad gesture guard (gesturestart / gesturechange / gestureend)
380
+ let gestureStartZoom = zoomRef.current;
381
+ const handleGestureStart = (e: Event) => {
382
+ e.preventDefault();
383
+ gestureStartZoom = zoomRef.current;
384
+ };
385
+ const handleGestureChange = (e: Event) => {
386
+ e.preventDefault();
387
+ const gestureEvent = e as unknown as {
388
+ scale?: number;
389
+ clientX?: number;
390
+ clientY?: number;
391
+ };
392
+ if (typeof gestureEvent.scale === 'number' && viewport) {
393
+ const rect = viewport.getBoundingClientRect();
394
+ const cx = gestureEvent.clientX ?? rect.left + rect.width / 2;
395
+ const cy = gestureEvent.clientY ?? rect.top + rect.height / 2;
396
+ applyFocalZoom(gestureStartZoom * gestureEvent.scale, cx, cy);
397
+ }
398
+ };
399
+ const handleGestureEnd = (e: Event) => {
400
+ e.preventDefault();
401
+ };
402
+
403
+ viewport?.addEventListener('wheel', handleCanvasWheel, { passive: false });
404
+ document.addEventListener('wheel', handleDocumentWheelGuard, { passive: false });
405
+ document.addEventListener('gesturestart', handleGestureStart, { passive: false });
406
+ document.addEventListener('gesturechange', handleGestureChange, { passive: false });
407
+ document.addEventListener('gestureend', handleGestureEnd, { passive: false });
408
+
409
+ return () => {
410
+ viewport?.removeEventListener('wheel', handleCanvasWheel);
411
+ document.removeEventListener('wheel', handleDocumentWheelGuard);
412
+ document.removeEventListener('gesturestart', handleGestureStart);
413
+ document.removeEventListener('gesturechange', handleGestureChange);
414
+ document.removeEventListener('gestureend', handleGestureEnd);
415
+ };
416
+ }, []);
417
+
418
+ // Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
419
+ const focusOnScreen = useCallback(
420
+ (screenId: string) => {
421
+ const pos = layout[screenId];
422
+ const viewport = canvasViewportRef.current;
423
+ if (!pos || !viewport) return;
424
+
425
+ const rect = viewport.getBoundingClientRect();
426
+ const targetZoom = Math.min(Math.max(zoom, 0.72), 0.95);
427
+ const nextPanX = rect.width / 2 - (pos.x + pos.width / 2) * targetZoom;
428
+ const nextPanY = rect.height / 2 - (pos.y + pos.height / 2) * targetZoom;
429
+
430
+ triggerCameraTransition();
431
+ setZoom(targetZoom);
432
+ setPan({ x: Math.round(nextPanX), y: Math.round(nextPanY) });
433
+ setActiveScreenId(screenId);
434
+ setHighlightedScreenId(screenId);
435
+ window.setTimeout(() => setHighlightedScreenId(null), 1600);
436
+ },
437
+ [layout, zoom, triggerCameraTransition]
438
+ );
439
+
440
+ // Fit all screens in viewport
441
+ const handleFitAllScreens = useCallback(() => {
442
+ const viewport = canvasViewportRef.current;
443
+ if (!viewport || screens.length === 0) return;
444
+ const rect = viewport.getBoundingClientRect();
445
+
446
+ const positions = Object.values(layout);
447
+ if (positions.length === 0) return;
448
+
449
+ const minX = Math.min(...positions.map((p) => p.x));
450
+ const minY = Math.min(...positions.map((p) => p.y));
451
+ const maxX = Math.max(...positions.map((p) => p.x + p.width));
452
+ const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
453
+
454
+ const contentWidth = maxX - minX + 140;
455
+ const contentHeight = maxY - minY + 140;
456
+
457
+ const scaleX = rect.width / contentWidth;
458
+ const scaleY = rect.height / contentHeight;
459
+ const nextZoom = Number(Math.min(Math.max(Math.min(scaleX, scaleY), 0.28), 1.0).toFixed(2));
460
+
461
+ const centerX = (minX + maxX) / 2;
462
+ const centerY = (minY + maxY) / 2;
463
+
464
+ triggerCameraTransition();
465
+ setZoom(nextZoom);
466
+ setPan({
467
+ x: Math.round(rect.width / 2 - centerX * nextZoom),
468
+ y: Math.round(rect.height / 2 - centerY * nextZoom),
469
+ });
470
+ }, [layout, screens.length, triggerCameraTransition]);
471
+
472
+ // Reset layout to clean horizontal grid
473
+ const handleResetGridLayout = useCallback(() => {
474
+ const reset = buildDefaultLayout(screens);
475
+ triggerCameraTransition();
476
+ setLayout(reset);
477
+ void fetch('/api/layout', {
478
+ method: 'POST',
479
+ headers: { 'Content-Type': 'application/json' },
480
+ body: JSON.stringify(reset),
481
+ });
482
+ }, [screens, triggerCameraTransition]);
483
+
484
+ // Move single artboard
485
+ const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
486
+ setLayout((prev) => {
487
+ const current = prev[screenId] ?? {
488
+ x: nextX,
489
+ y: nextY,
490
+ width: DEFAULT_WIDTH,
491
+ height: DEFAULT_HEIGHT,
492
+ };
493
+ const updated = {
494
+ ...prev,
495
+ [screenId]: { ...current, x: nextX, y: nextY },
496
+ };
497
+ return updated;
498
+ });
499
+ }, []);
500
+
501
+ // Handle interactive navigation inside a React Native screen
502
+ const handleNavigateFlow = useCallback(
503
+ (_fromScreen: string, toScreen: string) => {
504
+ focusOnScreen(toScreen);
505
+ },
506
+ [focusOnScreen]
507
+ );
508
+
509
+ // Canvas background pointer pan (0ms direct manipulation override)
510
+ const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
511
+ if (e.button !== 0 && e.button !== 1) return;
512
+ setIsCameraAnimating(false);
513
+ setIsPanning(true);
514
+
515
+ const startX = e.clientX;
516
+ const startY = e.clientY;
517
+ const startPanX = pan.x;
518
+ const startPanY = pan.y;
519
+
520
+ const handleMove = (moveEvent: PointerEvent) => {
521
+ const nextPan = {
522
+ x: startPanX + (moveEvent.clientX - startX),
523
+ y: startPanY + (moveEvent.clientY - startY),
524
+ };
525
+ panRef.current = nextPan;
526
+ setPan(nextPan);
527
+ };
528
+
529
+ const handleUp = () => {
530
+ setIsPanning(false);
531
+ window.removeEventListener('pointermove', handleMove);
532
+ window.removeEventListener('pointerup', handleUp);
533
+ };
534
+
535
+ window.addEventListener('pointermove', handleMove);
536
+ window.addEventListener('pointerup', handleUp);
537
+ };
538
+
539
+ return (
540
+ <div
541
+ className="zelune-studio-root"
542
+ data-density="spacious"
543
+ style={{
544
+ display: 'flex',
545
+ flexDirection: 'column',
546
+ width: '100vw',
547
+ height: '100vh',
548
+ backgroundColor: 'var(--studio-canvas-bg)',
549
+ color: 'var(--studio-text-primary)',
550
+ overflow: 'hidden',
551
+ }}
552
+ >
553
+ {/* Top Studio Bar */}
554
+ <header
555
+ style={{
556
+ height: 52,
557
+ backgroundColor: 'var(--studio-surface-1)',
558
+ borderBottom: '1px solid var(--studio-border-hairline)',
559
+ display: 'flex',
560
+ alignItems: 'center',
561
+ justifyContent: 'space-between',
562
+ padding: '0 14px',
563
+ zIndex: 40,
564
+ }}
565
+ >
566
+ {/* Left: Sidebar Toggle + Architectural Brand Identity */}
567
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
568
+ <button
569
+ type="button"
570
+ className="zelune-interactive"
571
+ onClick={() => setIsLeftPanelOpen((prev) => !prev)}
572
+ title={`${isLeftPanelOpen ? 'Collapse' : 'Expand'} Screens Sidebar ([)`}
573
+ style={{
574
+ display: 'inline-flex',
575
+ alignItems: 'center',
576
+ justifyContent: 'center',
577
+ width: 30,
578
+ height: 30,
579
+ borderRadius: 7,
580
+ border: `1px solid ${
581
+ isLeftPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
582
+ }`,
583
+ backgroundColor: isLeftPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
584
+ color: isLeftPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
585
+ cursor: 'pointer',
586
+ }}
587
+ >
588
+ {isLeftPanelOpen ? <PanelLeftClose size={14} /> : <PanelLeftOpen size={14} />}
589
+ </button>
590
+
591
+ <div
592
+ style={{
593
+ width: 30,
594
+ height: 30,
595
+ borderRadius: 7,
596
+ backgroundColor: 'var(--studio-surface-2)',
597
+ border: '1px solid rgba(56, 189, 248, 0.35)',
598
+ boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
599
+ display: 'flex',
600
+ alignItems: 'center',
601
+ justifyContent: 'center',
602
+ fontFamily: 'var(--font-mono)',
603
+ fontWeight: 700,
604
+ fontSize: 12,
605
+ letterSpacing: '-0.03em',
606
+ color: '#38BDF8',
607
+ }}
608
+ >
609
+ ZL
610
+ </div>
611
+ <div>
612
+ <div style={{ fontSize: 13.5, fontWeight: 700, letterSpacing: '-0.02em', color: '#F8FAFC' }}>
613
+ Zelune
614
+ </div>
615
+ <div
616
+ style={{
617
+ fontSize: 11,
618
+ color: 'var(--studio-text-secondary)',
619
+ fontFamily: 'var(--font-mono)',
620
+ marginTop: 1,
621
+ }}
622
+ >
623
+ {projectName}
624
+ </div>
625
+ </div>
626
+ </div>
627
+
628
+ {/* Center: Icon-First Mode Switcher & Flow Toggle (Reveals Label on Hover) */}
629
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
630
+ <div
631
+ style={{
632
+ display: 'flex',
633
+ alignItems: 'center',
634
+ gap: 2,
635
+ backgroundColor: 'var(--studio-canvas-bg)',
636
+ padding: 3,
637
+ borderRadius: 8,
638
+ border: '1px solid var(--studio-border-subtle)',
639
+ }}
640
+ >
641
+ <button
642
+ type="button"
643
+ className="zelune-interactive zelune-icon-reveal-btn"
644
+ onClick={() => setMode('inspect')}
645
+ title="Inspect & Edit"
646
+ aria-label="Inspect & Edit"
647
+ style={{
648
+ padding: '6px 9px',
649
+ borderRadius: 5,
650
+ border: mode === 'inspect' ? '1px solid rgba(56, 189, 248, 0.35)' : '1px solid transparent',
651
+ backgroundColor: mode === 'inspect' ? 'var(--studio-surface-3)' : 'transparent',
652
+ color: mode === 'inspect' ? '#38BDF8' : 'var(--studio-text-secondary)',
653
+ fontSize: 12,
654
+ fontWeight: 600,
655
+ cursor: 'pointer',
656
+ }}
657
+ >
658
+ <MousePointer size={14} style={{ flexShrink: 0 }} />
659
+ <span className="zelune-reveal-label">Inspect &amp; Edit</span>
660
+ </button>
661
+
662
+ <button
663
+ type="button"
664
+ className="zelune-interactive zelune-icon-reveal-btn"
665
+ onClick={() => setMode('interact')}
666
+ title="Interact & Navigate"
667
+ aria-label="Interact & Navigate"
668
+ style={{
669
+ padding: '6px 9px',
670
+ borderRadius: 5,
671
+ border: mode === 'interact' ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid transparent',
672
+ backgroundColor: mode === 'interact' ? 'rgba(6, 78, 59, 0.45)' : 'transparent',
673
+ color: mode === 'interact' ? '#34D399' : 'var(--studio-text-secondary)',
674
+ fontSize: 12,
675
+ fontWeight: 600,
676
+ cursor: 'pointer',
677
+ }}
678
+ >
679
+ <Play size={14} style={{ flexShrink: 0 }} />
680
+ <span className="zelune-reveal-label">Interact &amp; Navigate</span>
681
+ </button>
682
+ </div>
683
+
684
+ <button
685
+ type="button"
686
+ className="zelune-interactive zelune-icon-reveal-btn"
687
+ onClick={() => setShowFlows((prev) => !prev)}
688
+ title="Toggle Flow Graph"
689
+ aria-label="Toggle Flow Graph"
690
+ style={{
691
+ padding: '7px 9px',
692
+ borderRadius: 7,
693
+ border: `1px solid ${showFlows ? 'rgba(99, 102, 241, 0.45)' : 'var(--studio-border-subtle)'}`,
694
+ backgroundColor: showFlows ? 'rgba(30, 27, 75, 0.55)' : 'var(--studio-surface-2)',
695
+ color: showFlows ? '#C7D2FE' : 'var(--studio-text-secondary)',
696
+ fontSize: 12,
697
+ fontWeight: 600,
698
+ cursor: 'pointer',
699
+ }}
700
+ >
701
+ <GitCommit size={14} style={{ flexShrink: 0 }} />
702
+ <span className="zelune-reveal-label">Flow Graph</span>
703
+ </button>
704
+ </div>
705
+
706
+ {/* Right: Undo/Redo, Viewport Controls & Right Inspector Toggle */}
707
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
708
+ <button
709
+ type="button"
710
+ className="zelune-interactive tabular-nums"
711
+ onClick={handleUndo}
712
+ disabled={historyState.undoAvailable === 0}
713
+ title="Undo AST Patch (Cmd+Z)"
714
+ style={{
715
+ display: 'inline-flex',
716
+ alignItems: 'center',
717
+ gap: 4,
718
+ padding: '6px 9px',
719
+ borderRadius: 6,
720
+ border: '1px solid var(--studio-border-subtle)',
721
+ backgroundColor: 'var(--studio-surface-2)',
722
+ color: historyState.undoAvailable > 0 ? '#E2E8F0' : '#475569',
723
+ fontSize: 11.5,
724
+ fontFamily: 'var(--font-mono)',
725
+ cursor: historyState.undoAvailable > 0 ? 'pointer' : 'not-allowed',
726
+ }}
727
+ >
728
+ <Undo2 size={13} />
729
+ <span>{historyState.undoAvailable}</span>
730
+ </button>
731
+
732
+ <button
733
+ type="button"
734
+ className="zelune-interactive"
735
+ onClick={handleRedo}
736
+ disabled={historyState.redoAvailable === 0}
737
+ title="Redo AST Patch (Cmd+Shift+Z)"
738
+ style={{
739
+ display: 'inline-flex',
740
+ alignItems: 'center',
741
+ gap: 4,
742
+ padding: '6px 9px',
743
+ borderRadius: 6,
744
+ border: '1px solid var(--studio-border-subtle)',
745
+ backgroundColor: 'var(--studio-surface-2)',
746
+ color: historyState.redoAvailable > 0 ? '#E2E8F0' : '#475569',
747
+ fontSize: 11.5,
748
+ cursor: historyState.redoAvailable > 0 ? 'pointer' : 'not-allowed',
749
+ }}
750
+ >
751
+ <Redo2 size={13} />
752
+ </button>
753
+
754
+ <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
755
+
756
+ <button
757
+ type="button"
758
+ className="zelune-interactive"
759
+ onClick={() => {
760
+ triggerCameraTransition();
761
+ setZoom((z) => clampZoom(z - 0.1));
762
+ }}
763
+ title="Zoom Out"
764
+ style={{
765
+ padding: 6,
766
+ borderRadius: 6,
767
+ border: '1px solid var(--studio-border-subtle)',
768
+ backgroundColor: 'var(--studio-surface-2)',
769
+ color: '#CBD5E1',
770
+ cursor: 'pointer',
771
+ }}
772
+ >
773
+ <ZoomOut size={13} />
774
+ </button>
775
+
776
+ <span
777
+ className="tabular-nums"
778
+ style={{
779
+ minWidth: 46,
780
+ textAlign: 'center',
781
+ fontSize: 11.5,
782
+ fontFamily: 'var(--font-mono)',
783
+ color: '#CBD5E1',
784
+ }}
785
+ >
786
+ {Math.round(zoom * 100)}%
787
+ </span>
788
+
789
+ <button
790
+ type="button"
791
+ className="zelune-interactive"
792
+ onClick={() => {
793
+ triggerCameraTransition();
794
+ setZoom((z) => clampZoom(z + 0.1));
795
+ }}
796
+ title="Zoom In"
797
+ style={{
798
+ padding: 6,
799
+ borderRadius: 6,
800
+ border: '1px solid var(--studio-border-subtle)',
801
+ backgroundColor: 'var(--studio-surface-2)',
802
+ color: '#CBD5E1',
803
+ cursor: 'pointer',
804
+ }}
805
+ >
806
+ <ZoomIn size={13} />
807
+ </button>
808
+
809
+ <button
810
+ type="button"
811
+ className="zelune-interactive zelune-icon-reveal-btn"
812
+ onClick={handleFitAllScreens}
813
+ title="Fit All Screens in Viewport"
814
+ aria-label="Fit All Screens in Viewport"
815
+ style={{
816
+ padding: '6px 8px',
817
+ borderRadius: 6,
818
+ border: '1px solid var(--studio-border-subtle)',
819
+ backgroundColor: 'var(--studio-surface-2)',
820
+ color: '#CBD5E1',
821
+ fontSize: 11.5,
822
+ fontWeight: 500,
823
+ cursor: 'pointer',
824
+ }}
825
+ >
826
+ <Maximize size={13} style={{ flexShrink: 0 }} />
827
+ <span className="zelune-reveal-label">Fit</span>
828
+ </button>
829
+
830
+ <button
831
+ type="button"
832
+ className="zelune-interactive zelune-icon-reveal-btn"
833
+ onClick={handleResetGridLayout}
834
+ title="Auto-Arrange Clean Artboard Grid"
835
+ aria-label="Auto-Arrange Clean Artboard Grid"
836
+ style={{
837
+ padding: '6px 8px',
838
+ borderRadius: 6,
839
+ border: '1px solid var(--studio-border-subtle)',
840
+ backgroundColor: 'var(--studio-surface-2)',
841
+ color: '#CBD5E1',
842
+ fontSize: 11.5,
843
+ fontWeight: 500,
844
+ cursor: 'pointer',
845
+ }}
846
+ >
847
+ <LayoutGrid size={13} style={{ flexShrink: 0 }} />
848
+ <span className="zelune-reveal-label">Grid</span>
849
+ </button>
850
+
851
+ <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
852
+
853
+ <button
854
+ type="button"
855
+ className="zelune-interactive"
856
+ onClick={() => setIsRightPanelOpen((prev) => !prev)}
857
+ title={`${isRightPanelOpen ? 'Collapse' : 'Expand'} AST Inspector Panel (])`}
858
+ style={{
859
+ display: 'inline-flex',
860
+ alignItems: 'center',
861
+ justifyContent: 'center',
862
+ width: 30,
863
+ height: 30,
864
+ borderRadius: 7,
865
+ border: `1px solid ${
866
+ isRightPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
867
+ }`,
868
+ backgroundColor: isRightPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
869
+ color: isRightPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
870
+ cursor: 'pointer',
871
+ }}
872
+ >
873
+ {isRightPanelOpen ? <PanelRightClose size={14} /> : <PanelRightOpen size={14} />}
874
+ </button>
875
+ </div>
876
+ </header>
877
+
878
+ {/* Main Studio Body: Collapsible Left Screen Rail + Infinite Canvas + Collapsible Right AST Inspector */}
879
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
880
+ {/* Left Sidebar: Screen Artboard Navigator */}
881
+ <aside
882
+ aria-hidden={!isLeftPanelOpen}
883
+ className="zelune-collapsible-panel"
884
+ style={{
885
+ width: isLeftPanelOpen ? 224 : 0,
886
+ opacity: isLeftPanelOpen ? 1 : 0,
887
+ pointerEvents: isLeftPanelOpen ? 'auto' : 'none',
888
+ backgroundColor: 'var(--studio-surface-1)',
889
+ borderRight: isLeftPanelOpen
890
+ ? '1px solid var(--studio-border-hairline)'
891
+ : '0px solid transparent',
892
+ display: 'flex',
893
+ flexDirection: 'column',
894
+ zIndex: 30,
895
+ }}
896
+ >
897
+ <div
898
+ className="zelune-scroller"
899
+ style={{
900
+ width: 224,
901
+ minWidth: 224,
902
+ height: '100%',
903
+ display: 'flex',
904
+ flexDirection: 'column',
905
+ justifyContent: 'space-between',
906
+ padding: 12,
907
+ overflowY: 'auto',
908
+ }}
909
+ >
910
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
911
+ <div
912
+ className="tabular-nums"
913
+ style={{
914
+ display: 'flex',
915
+ alignItems: 'center',
916
+ justifyContent: 'space-between',
917
+ padding: '4px 6px 6px',
918
+ fontSize: 10.5,
919
+ fontFamily: 'var(--font-mono)',
920
+ fontWeight: 600,
921
+ color: 'var(--studio-text-muted)',
922
+ letterSpacing: '0.06em',
923
+ }}
924
+ >
925
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
926
+ <Layers size={12} color="#38BDF8" />
927
+ <span>SCREENS ({screens.length})</span>
928
+ </span>
929
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
930
+ <button
931
+ type="button"
932
+ className="zelune-interactive"
933
+ onClick={() => void fetchScreens()}
934
+ title="Refresh Screen Discovery"
935
+ style={{
936
+ background: 'transparent',
937
+ border: '1px solid transparent',
938
+ borderRadius: 4,
939
+ color: 'var(--studio-text-muted)',
940
+ cursor: 'pointer',
941
+ padding: 3,
942
+ display: 'inline-flex',
943
+ alignItems: 'center',
944
+ }}
945
+ >
946
+ <RefreshCw size={11} />
947
+ </button>
948
+ <button
949
+ type="button"
950
+ className="zelune-interactive"
951
+ onClick={() => setIsLeftPanelOpen(false)}
952
+ title="Collapse Screens Sidebar ([)"
953
+ style={{
954
+ background: 'transparent',
955
+ border: '1px solid transparent',
956
+ borderRadius: 4,
957
+ color: 'var(--studio-text-muted)',
958
+ cursor: 'pointer',
959
+ padding: 3,
960
+ display: 'inline-flex',
961
+ alignItems: 'center',
962
+ }}
963
+ >
964
+ <PanelLeftClose size={12} />
965
+ </button>
966
+ </div>
967
+ </div>
968
+
969
+ {screens.map((scr) => {
970
+ const isSelected = activeScreenId === scr.id;
971
+ return (
972
+ <button
973
+ key={scr.id}
974
+ type="button"
975
+ className="zelune-interactive zelune-rail-item"
976
+ onClick={() => focusOnScreen(scr.id)}
977
+ style={{
978
+ display: 'flex',
979
+ alignItems: 'center',
980
+ justifyContent: 'space-between',
981
+ width: '100%',
982
+ paddingLeft: 10,
983
+ paddingRight: 10,
984
+ borderRadius: 7,
985
+ border: `1px solid ${
986
+ isSelected ? 'rgba(56, 189, 248, 0.35)' : 'transparent'
987
+ }`,
988
+ backgroundColor: isSelected ? 'var(--studio-surface-2)' : 'transparent',
989
+ color: isSelected ? '#F8FAFC' : 'var(--studio-text-secondary)',
990
+ fontSize: 12.5,
991
+ fontWeight: isSelected ? 600 : 500,
992
+ cursor: 'pointer',
993
+ textAlign: 'left',
994
+ }}
995
+ >
996
+ <span>{scr.id}</span>
997
+ <span
998
+ className="tabular-nums"
999
+ style={{
1000
+ fontSize: 10,
1001
+ fontFamily: 'var(--font-mono)',
1002
+ color: isSelected ? '#38BDF8' : 'var(--studio-text-muted)',
1003
+ }}
1004
+ >
1005
+ {scr.outgoingFlows.length}→
1006
+ </span>
1007
+ </button>
1008
+ );
1009
+ })}
1010
+ </div>
1011
+
1012
+ <div
1013
+ style={{
1014
+ backgroundColor: 'var(--studio-surface-2)',
1015
+ border: '1px solid var(--studio-border-hairline)',
1016
+ borderRadius: 9,
1017
+ padding: 11,
1018
+ fontSize: 11,
1019
+ color: 'var(--studio-text-secondary)',
1020
+ lineHeight: 1.55,
1021
+ }}
1022
+ >
1023
+ <div
1024
+ style={{
1025
+ fontFamily: 'var(--font-mono)',
1026
+ fontSize: 10,
1027
+ fontWeight: 600,
1028
+ letterSpacing: '0.05em',
1029
+ color: '#E2E8F0',
1030
+ marginBottom: 4,
1031
+ }}
1032
+ >
1033
+ STUDIO ERGONOMICS
1034
+ </div>
1035
+ <div>• Two-finger scroll to pan</div>
1036
+ <div>• Two-finger pinch to zoom</div>
1037
+ <div>• Press [ or ] to toggle panels</div>
1038
+ <div>• Click any node to patch AST</div>
1039
+ </div>
1040
+ </div>
1041
+ </aside>
1042
+
1043
+ {/* Center: Infinite Zoom/Pan Canvas */}
1044
+ <main
1045
+ ref={canvasViewportRef}
1046
+ onPointerDown={handleCanvasPointerDown}
1047
+ className="infinite-canvas-surface"
1048
+ style={{
1049
+ flex: 1,
1050
+ position: 'relative',
1051
+ overflow: 'hidden',
1052
+ cursor: isPanning ? 'grabbing' : 'grab',
1053
+ }}
1054
+ >
1055
+ {/* Floating Edge Restore Pill for Collapsed Left Sidebar */}
1056
+ {!isLeftPanelOpen && (
1057
+ <button
1058
+ type="button"
1059
+ className="zelune-interactive tabular-nums"
1060
+ onClick={(e) => {
1061
+ e.stopPropagation();
1062
+ setIsLeftPanelOpen(true);
1063
+ }}
1064
+ onPointerDown={(e) => e.stopPropagation()}
1065
+ title="Expand Screens Navigator ([)"
1066
+ style={{
1067
+ position: 'absolute',
1068
+ top: 14,
1069
+ left: 14,
1070
+ zIndex: 35,
1071
+ display: 'inline-flex',
1072
+ alignItems: 'center',
1073
+ gap: 7,
1074
+ padding: '7px 11px',
1075
+ borderRadius: 8,
1076
+ backgroundColor: 'var(--studio-surface-1)',
1077
+ border: '1px solid var(--studio-border-subtle)',
1078
+ color: '#F1F5F9',
1079
+ fontSize: 11.5,
1080
+ fontFamily: 'var(--font-mono)',
1081
+ fontWeight: 600,
1082
+ cursor: 'pointer',
1083
+ boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.65)',
1084
+ }}
1085
+ >
1086
+ <PanelLeftOpen size={13} color="#38BDF8" />
1087
+ <span>Screens ({screens.length})</span>
1088
+ </button>
1089
+ )}
1090
+
1091
+ {/* Floating Edge Restore Pill for Collapsed Right Inspector */}
1092
+ {!isRightPanelOpen && (
1093
+ <button
1094
+ type="button"
1095
+ className="zelune-interactive tabular-nums"
1096
+ onClick={(e) => {
1097
+ e.stopPropagation();
1098
+ setIsRightPanelOpen(true);
1099
+ }}
1100
+ onPointerDown={(e) => e.stopPropagation()}
1101
+ title="Expand AST Visual Inspector (])"
1102
+ style={{
1103
+ position: 'absolute',
1104
+ top: 14,
1105
+ right: 14,
1106
+ zIndex: 35,
1107
+ display: 'inline-flex',
1108
+ alignItems: 'center',
1109
+ gap: 7,
1110
+ padding: '7px 11px',
1111
+ borderRadius: 8,
1112
+ backgroundColor: 'var(--studio-surface-1)',
1113
+ border: '1px solid var(--studio-border-subtle)',
1114
+ color: '#F1F5F9',
1115
+ fontSize: 11.5,
1116
+ fontFamily: 'var(--font-mono)',
1117
+ fontWeight: 600,
1118
+ cursor: 'pointer',
1119
+ boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.65)',
1120
+ }}
1121
+ >
1122
+ <Sliders size={13} color="#38BDF8" />
1123
+ <span>
1124
+ {inspectedData ? `<${inspectedData.tagName}> L${inspectedData.line}` : 'AST Inspector'}
1125
+ </span>
1126
+ <PanelRightOpen size={13} color="#94A3B8" />
1127
+ </button>
1128
+ )}
1129
+
1130
+ {/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
1131
+ <div
1132
+ style={{
1133
+ position: 'absolute',
1134
+ top: 0,
1135
+ left: 0,
1136
+ transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
1137
+ transformOrigin: '0 0',
1138
+ willChange: 'transform',
1139
+ transition: isCameraAnimating
1140
+ ? 'transform var(--duration-camera) var(--ease-out-expo)'
1141
+ : 'none',
1142
+ }}
1143
+ >
1144
+ {showFlows && (
1145
+ <FlowArrowsLayer
1146
+ screens={screens}
1147
+ layout={layout}
1148
+ activeScreenId={activeScreenId}
1149
+ highlightedScreenId={highlightedScreenId}
1150
+ onSelectFlow={(edge: NavigationEdge) => focusOnScreen(edge.toScreen)}
1151
+ />
1152
+ )}
1153
+
1154
+ {screens.map((screen) => {
1155
+ const pos = layout[screen.id] ?? {
1156
+ x: INITIAL_OFFSET_X,
1157
+ y: INITIAL_OFFSET_Y,
1158
+ width: DEFAULT_WIDTH,
1159
+ height: DEFAULT_HEIGHT,
1160
+ };
1161
+ const ScreenComponent = SCREEN_COMPONENTS[screen.id];
1162
+
1163
+ return (
1164
+ <ScreenArtboard
1165
+ key={screen.id}
1166
+ screen={screen}
1167
+ ScreenComponent={ScreenComponent}
1168
+ position={pos}
1169
+ zoom={zoom}
1170
+ mode={mode}
1171
+ isActiveScreen={activeScreenId === screen.id}
1172
+ isHighlightedScreen={highlightedScreenId === screen.id}
1173
+ selectedElement={selectedElement}
1174
+ onSelectElement={handleSelectElement}
1175
+ onSelectScreen={setActiveScreenId}
1176
+ onFocusScreen={focusOnScreen}
1177
+ onMoveArtboard={handleMoveArtboard}
1178
+ onNavigateFlow={handleNavigateFlow}
1179
+ />
1180
+ );
1181
+ })}
1182
+ </div>
1183
+ </main>
1184
+
1185
+ {/* Right: AST Visual Inspector Panel */}
1186
+ <InspectorPanel
1187
+ inspected={inspectedData}
1188
+ isOpen={isRightPanelOpen}
1189
+ isLoading={isInspecting}
1190
+ isSaving={isSaving}
1191
+ statusMessage={statusMessage}
1192
+ onPatch={handlePatchElement}
1193
+ onClearSelection={() => {
1194
+ setSelectedElement(null);
1195
+ setInspectedData(null);
1196
+ }}
1197
+ onCollapse={() => setIsRightPanelOpen(false)}
1198
+ onJumpToScreen={focusOnScreen}
1199
+ />
1200
+ </div>
1201
+ </div>
1202
+ );
1203
+ };