zelune 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/cli.mjs +13 -54
  3. package/dist/assets/CheckoutScreen-6IdrpKgm.js +229 -0
  4. package/dist/assets/HomeScreen-Dxe9L4I2.js +316 -0
  5. package/dist/assets/LoginScreen-Dp1awI4F.js +189 -0
  6. package/dist/assets/ProductDetailScreen-Wa5_nlRP.js +196 -0
  7. package/dist/assets/ProfileScreen-riAU6vVh.js +185 -0
  8. package/dist/assets/index-hEzd4JVy.js +44697 -0
  9. package/{index.html → dist/index.html} +3 -7
  10. package/mcp/server.mjs +1370 -34
  11. package/package.json +10 -17
  12. package/plugin.json +1 -1
  13. package/socket.yml +11 -0
  14. package/mcp/server.ts +0 -446
  15. package/plugins/viteReactNativeFallbackPlugin.ts +0 -519
  16. package/plugins/viteRnSourceInject.ts +0 -117
  17. package/plugins/viteVirtualScreensPlugin.ts +0 -216
  18. package/src/canvas/CanvasApp.tsx +0 -1655
  19. package/src/canvas/FlowArrowsLayer.tsx +0 -239
  20. package/src/canvas/InspectorPanel.tsx +0 -1494
  21. package/src/canvas/ScreenArtboard.tsx +0 -1491
  22. package/src/canvas/types.ts +0 -23
  23. package/src/main.tsx +0 -80
  24. package/src/runtime/MockNavigationContext.tsx +0 -1795
  25. package/src/runtime/ScreenErrorBoundary.tsx +0 -86
  26. package/src/runtime/nativewindShim.ts +0 -997
  27. package/src/runtime/reactNativeShim.ts +0 -240
  28. package/src/screens/CheckoutScreen.tsx +0 -229
  29. package/src/screens/HomeScreen.tsx +0 -288
  30. package/src/screens/LoginScreen.tsx +0 -176
  31. package/src/screens/ProductDetailScreen.tsx +0 -207
  32. package/src/screens/ProfileScreen.tsx +0 -172
  33. package/src/screens/index.ts +0 -1
  34. package/src/server/apiMiddleware.ts +0 -375
  35. package/src/server/astEngine.ts +0 -2553
  36. package/src/vite-env.d.ts +0 -7
  37. package/tsconfig.json +0 -23
  38. package/vite.config.ts +0 -340
  39. /package/{src/styles.css → dist/assets/index-C7nrS82N.css} +0 -0
@@ -1,1655 +0,0 @@
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
- Sun,
20
- Moon,
21
- Plus,
22
- } from 'lucide-react';
23
- import { SCREEN_COMPONENTS, SCREEN_ORDER } from '../screens';
24
- import { ScreenArtboard } from './ScreenArtboard';
25
- import { FlowArrowsLayer } from './FlowArrowsLayer';
26
- import { InspectorPanel } from './InspectorPanel';
27
- import type {
28
- ArtboardLayoutMap,
29
- CanvasMode,
30
- InspectedElement,
31
- NavigationEdge,
32
- ScreenMetadata,
33
- SelectedElementRef,
34
- } from './types';
35
- import type {
36
- PatchPayload,
37
- ReorderPayload,
38
- InsertElementPayload,
39
- DuplicateElementPayload,
40
- DeleteElementPayload,
41
- } from '../server/astEngine';
42
-
43
- const DEFAULT_WIDTH = 393;
44
- const DEFAULT_HEIGHT = 852;
45
- const ARTBOARD_GAP = 160;
46
- const ARTBOARD_ROW_GAP = 220;
47
- const INITIAL_OFFSET_X = 80;
48
- const INITIAL_OFFSET_Y = 70;
49
- const MIN_ZOOM = 0.08;
50
- const MAX_ZOOM = 2.0;
51
-
52
- function clampZoom(z: number): number {
53
- return Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM);
54
- }
55
-
56
- function snapToDevicePixel(v: number): number {
57
- const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
58
- return Math.round(v * dpr) / dpr;
59
- }
60
-
61
- function buildDefaultLayout(screens: ScreenMetadata[]): ArtboardLayoutMap {
62
- const map: ArtboardLayoutMap = {};
63
- const cols =
64
- screens.length <= 5
65
- ? Math.max(1, screens.length)
66
- : Math.min(7, Math.ceil(Math.sqrt(screens.length * 1.6)));
67
- screens.forEach((screen, idx) => {
68
- const col = idx % cols;
69
- const row = Math.floor(idx / cols);
70
- map[screen.id] = {
71
- x: INITIAL_OFFSET_X + col * (DEFAULT_WIDTH + ARTBOARD_GAP),
72
- y: INITIAL_OFFSET_Y + row * (DEFAULT_HEIGHT + ARTBOARD_ROW_GAP),
73
- width: DEFAULT_WIDTH,
74
- height: DEFAULT_HEIGHT,
75
- };
76
- });
77
- return map;
78
- }
79
-
80
- export const CanvasApp: React.FC = () => {
81
- const [screens, setScreens] = useState<ScreenMetadata[]>([]);
82
- const [projectName, setProjectName] = useState<string>('Google Artifact');
83
- const [layout, setLayout] = useState<ArtboardLayoutMap>({});
84
- const [mode, setMode] = useState<CanvasMode>('inspect');
85
- const [showFlows, setShowFlows] = useState<boolean>(true);
86
-
87
- // Collapsible Left (Screen Rail) & Right (AST Inspector) Panels
88
- const [isLeftPanelOpen, setIsLeftPanelOpen] = useState<boolean>(true);
89
- const [isRightPanelOpen, setIsRightPanelOpen] = useState<boolean>(true);
90
-
91
- // Studio color-scheme state ('dark' default, persisted in localStorage & <meta name="color-scheme">)
92
- const [colorScheme, setColorScheme] = useState<'dark' | 'light'>(() => {
93
- try {
94
- return localStorage.getItem('color-scheme') === 'light' ? 'light' : 'dark';
95
- } catch {
96
- return 'dark';
97
- }
98
- });
99
-
100
- const applyColorScheme = useCallback((next: 'dark' | 'light') => {
101
- setColorScheme(next);
102
- const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
103
- if (meta) {
104
- meta.content = next;
105
- }
106
- try {
107
- localStorage.setItem('color-scheme', next);
108
- } catch {
109
- // Ignore storage write errors in restricted environments
110
- }
111
- }, []);
112
-
113
- const toggleColorScheme = useCallback(() => {
114
- applyColorScheme(colorScheme === 'dark' ? 'light' : 'dark');
115
- }, [colorScheme, applyColorScheme]);
116
-
117
- useEffect(() => {
118
- const handleStorage = (e: StorageEvent) => {
119
- if (e.key !== 'color-scheme') return;
120
- const next = e.newValue === 'light' ? 'light' : 'dark';
121
- setColorScheme(next);
122
- const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
123
- if (meta) {
124
- meta.content = next;
125
- }
126
- };
127
- window.addEventListener('storage', handleStorage);
128
- return () => window.removeEventListener('storage', handleStorage);
129
- }, []);
130
-
131
- const [pan, setPan] = useState<{ x: number; y: number }>({ x: 20, y: 10 });
132
- const [zoom, setZoom] = useState<number>(0.68);
133
- const [isPanning, setIsPanning] = useState<boolean>(false);
134
- const [isCameraAnimating, setIsCameraAnimating] = useState<boolean>(false);
135
-
136
- const [activeScreenId, setActiveScreenId] = useState<string | null>('Home');
137
- const [highlightedScreenId, setHighlightedScreenId] = useState<string | null>(null);
138
- const [selectedElement, setSelectedElement] = useState<SelectedElementRef | null>(null);
139
- const [inspectedData, setInspectedData] = useState<InspectedElement | null>(null);
140
-
141
- const [isInspecting, setIsInspecting] = useState<boolean>(false);
142
- const [isSaving, setIsSaving] = useState<boolean>(false);
143
- const [statusMessage, setStatusMessage] = useState<string | null>(null);
144
- const [historyState, setHistoryState] = useState<{
145
- undoAvailable: number;
146
- redoAvailable: number;
147
- }>({ undoAvailable: 0, redoAvailable: 0 });
148
-
149
- const canvasViewportRef = useRef<HTMLDivElement | null>(null);
150
- const worldContainerRef = useRef<HTMLDivElement | null>(null);
151
- const zoomLabelRef = useRef<HTMLSpanElement | null>(null);
152
- const cameraTimerRef = useRef<number | null>(null);
153
- const panSyncTimerRef = useRef<number | null>(null);
154
- const hasInitialFitRef = useRef<boolean>(false);
155
-
156
- // Refs for synchronous access inside non-passive native wheel/gesture listeners
157
- const zoomRef = useRef<number>(zoom);
158
- const panRef = useRef<{ x: number; y: number }>(pan);
159
- useEffect(() => {
160
- zoomRef.current = zoom;
161
- if (zoomLabelRef.current) {
162
- zoomLabelRef.current.textContent = `${Math.round(zoom * 100)}%`;
163
- }
164
- }, [zoom]);
165
- useEffect(() => {
166
- panRef.current = pan;
167
- }, [pan]);
168
-
169
- const scheduleCameraStateSync = useCallback((commitImmediately = false) => {
170
- if (panSyncTimerRef.current) {
171
- window.clearTimeout(panSyncTimerRef.current);
172
- panSyncTimerRef.current = null;
173
- }
174
- if (commitImmediately) {
175
- setIsCameraAnimating(false);
176
- setZoom(zoomRef.current);
177
- setPan(panRef.current);
178
- } else {
179
- panSyncTimerRef.current = window.setTimeout(() => {
180
- panSyncTimerRef.current = null;
181
- setIsCameraAnimating(false);
182
- setZoom(zoomRef.current);
183
- setPan(panRef.current);
184
- }, 140);
185
- }
186
- }, []);
187
-
188
- const applyDirectPanTransform = useCallback(
189
- (rawX: number, rawY: number, commitImmediately = false) => {
190
- const nextPan = {
191
- x: snapToDevicePixel(rawX),
192
- y: snapToDevicePixel(rawY),
193
- };
194
- panRef.current = nextPan;
195
-
196
- const worldEl = worldContainerRef.current;
197
- if (worldEl) {
198
- worldEl.style.transition = 'none';
199
- worldEl.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${zoomRef.current})`;
200
- }
201
-
202
- scheduleCameraStateSync(commitImmediately);
203
- },
204
- [scheduleCameraStateSync]
205
- );
206
-
207
- const triggerCameraTransition = useCallback(() => {
208
- if (cameraTimerRef.current) {
209
- window.clearTimeout(cameraTimerRef.current);
210
- }
211
- setIsCameraAnimating(true);
212
- cameraTimerRef.current = window.setTimeout(() => {
213
- setIsCameraAnimating(false);
214
- }, 390);
215
- }, []);
216
-
217
- const sortScreensByOrder = useCallback((rawScreens: ScreenMetadata[]) => {
218
- return [...rawScreens].sort((a, b) => {
219
- const idxA = SCREEN_ORDER.indexOf(a.id);
220
- const idxB = SCREEN_ORDER.indexOf(b.id);
221
- if (idxA === -1 && idxB === -1) return a.id.localeCompare(b.id);
222
- if (idxA === -1) return 1;
223
- if (idxB === -1) return -1;
224
- return idxA - idxB;
225
- });
226
- }, []);
227
-
228
- const fetchScreens = useCallback(async () => {
229
- try {
230
- const [screensRes, layoutRes] = await Promise.all([
231
- fetch('/api/screens'),
232
- fetch('/api/layout'),
233
- ]);
234
- const screensJson = (await screensRes.json()) as {
235
- ok: boolean;
236
- projectName?: string;
237
- screens: ScreenMetadata[];
238
- history?: { undoAvailable: number; redoAvailable: number };
239
- };
240
- const layoutJson = (await layoutRes.json()) as {
241
- ok: boolean;
242
- layout: ArtboardLayoutMap | null;
243
- };
244
-
245
- if (screensJson.ok) {
246
- if (screensJson.projectName) {
247
- setProjectName(screensJson.projectName);
248
- }
249
- const sorted = sortScreensByOrder(screensJson.screens);
250
- setScreens(sorted);
251
- setActiveScreenId((prev) => {
252
- if (prev && sorted.some((s) => s.id === prev)) return prev;
253
- const home = sorted.find((s) => s.id.toLowerCase() === 'home');
254
- return home ? home.id : (sorted[0]?.id ?? null);
255
- });
256
- if (screensJson.history) {
257
- setHistoryState(screensJson.history);
258
- }
259
-
260
- setLayout((prev) => {
261
- const base = buildDefaultLayout(sorted);
262
- const saved = layoutJson.ok && layoutJson.layout ? layoutJson.layout : {};
263
- const merged = { ...base, ...saved, ...prev };
264
- if (!hasInitialFitRef.current && sorted.length > 0 && canvasViewportRef.current) {
265
- hasInitialFitRef.current = true;
266
- const rect = canvasViewportRef.current.getBoundingClientRect();
267
- const positions = Object.values(merged);
268
- if (positions.length > 0 && rect.width > 0 && rect.height > 0) {
269
- const minX = Math.min(...positions.map((p) => p.x));
270
- const minY = Math.min(...positions.map((p) => p.y));
271
- const maxX = Math.max(...positions.map((p) => p.x + p.width));
272
- const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
273
- const contentWidth = maxX - minX + 140;
274
- const contentHeight = maxY - minY + 140;
275
- const nextZoom = clampZoom(
276
- Math.min(rect.width / contentWidth, rect.height / contentHeight, 0.85)
277
- );
278
- const centerX = (minX + maxX) / 2;
279
- const centerY = (minY + maxY) / 2;
280
- const nextPan = {
281
- x: Math.round(rect.width / 2 - centerX * nextZoom),
282
- y: Math.round(rect.height / 2 - centerY * nextZoom),
283
- };
284
- zoomRef.current = nextZoom;
285
- panRef.current = nextPan;
286
- setZoom(nextZoom);
287
- setPan(nextPan);
288
- }
289
- }
290
- return merged;
291
- });
292
- }
293
- } catch (err) {
294
- console.error('Failed to load screens metadata:', err);
295
- }
296
- }, [sortScreensByOrder]);
297
-
298
- useEffect(() => {
299
- void fetchScreens();
300
- }, [fetchScreens]);
301
-
302
- // Fetch AST inspection details when an element is selected
303
- const inspectSelectedElement = useCallback(async (ref: SelectedElementRef) => {
304
- setIsInspecting(true);
305
- try {
306
- const params = new URLSearchParams({
307
- file: ref.file,
308
- astPath: ref.astPath,
309
- line: String(ref.line),
310
- });
311
- const res = await fetch(`/api/inspect?${params.toString()}`);
312
- const data = (await res.json()) as {
313
- ok: boolean;
314
- element?: InspectedElement;
315
- history?: { undoAvailable: number; redoAvailable: number };
316
- error?: string;
317
- };
318
- if (data.ok && data.element) {
319
- setInspectedData(data.element);
320
- if (data.history) setHistoryState(data.history);
321
- }
322
- } catch (err) {
323
- console.error('Inspect error:', err);
324
- } finally {
325
- setIsInspecting(false);
326
- }
327
- }, []);
328
-
329
- const handleSelectElement = useCallback(
330
- (ref: SelectedElementRef) => {
331
- setSelectedElement(ref);
332
- setActiveScreenId(ref.screenId);
333
- setIsRightPanelOpen(true);
334
- void inspectSelectedElement(ref);
335
- },
336
- [inspectSelectedElement]
337
- );
338
-
339
- // Surgical AST Patch handler
340
- const handlePatchElement = useCallback(
341
- async (payload: PatchPayload) => {
342
- setIsSaving(true);
343
- setStatusMessage(null);
344
- try {
345
- const res = await fetch('/api/patch', {
346
- method: 'POST',
347
- headers: { 'Content-Type': 'application/json' },
348
- body: JSON.stringify(payload),
349
- });
350
- const data = (await res.json()) as {
351
- ok: boolean;
352
- element?: InspectedElement;
353
- history?: { undoAvailable: number; redoAvailable: number };
354
- error?: string;
355
- };
356
- if (data.ok && data.element) {
357
- setInspectedData(data.element);
358
- if (data.history) setHistoryState(data.history);
359
- setSelectedElement((prev) =>
360
- prev
361
- ? {
362
- ...prev,
363
- line: data.element!.line,
364
- col: data.element!.col,
365
- astPath: data.element!.astPath,
366
- }
367
- : null
368
- );
369
- setStatusMessage('Saved to .tsx');
370
- void fetchScreens();
371
- } else if (data.error) {
372
- setStatusMessage(`Error: ${data.error}`);
373
- }
374
- } catch (err) {
375
- setStatusMessage('Failed to write AST patch');
376
- } finally {
377
- setIsSaving(false);
378
- }
379
- },
380
- [fetchScreens]
381
- );
382
-
383
- // Surgical AST Sibling Reorder handler
384
- const handleReorderElement = useCallback(
385
- async (payload: ReorderPayload) => {
386
- setIsSaving(true);
387
- setStatusMessage(null);
388
- try {
389
- const res = await fetch('/api/reorder', {
390
- method: 'POST',
391
- headers: { 'Content-Type': 'application/json' },
392
- body: JSON.stringify(payload),
393
- });
394
- const data = (await res.json()) as {
395
- ok: boolean;
396
- element?: InspectedElement;
397
- history?: { undoAvailable: number; redoAvailable: number };
398
- error?: string;
399
- };
400
- if (data.ok && data.element) {
401
- setInspectedData(data.element);
402
- if (data.history) setHistoryState(data.history);
403
- setSelectedElement((prev) =>
404
- prev
405
- ? {
406
- ...prev,
407
- line: data.element!.line,
408
- col: data.element!.col,
409
- astPath: data.element!.astPath,
410
- }
411
- : null
412
- );
413
- setStatusMessage('Reordered in .tsx');
414
- void fetchScreens();
415
- } else if (data.error) {
416
- setStatusMessage(`Error: ${data.error}`);
417
- }
418
- } catch {
419
- setStatusMessage('Failed to reorder AST element');
420
- } finally {
421
- setIsSaving(false);
422
- }
423
- },
424
- [fetchScreens]
425
- );
426
-
427
- // Surgical AST Component / Section Insertion handler
428
- const handleInsertElement = useCallback(
429
- async (payload: InsertElementPayload) => {
430
- setIsSaving(true);
431
- setStatusMessage(null);
432
- try {
433
- const res = await fetch('/api/insert', {
434
- method: 'POST',
435
- headers: { 'Content-Type': 'application/json' },
436
- body: JSON.stringify(payload),
437
- });
438
- const data = (await res.json()) as {
439
- ok: boolean;
440
- element?: InspectedElement;
441
- history?: { undoAvailable: number; redoAvailable: number };
442
- error?: string;
443
- };
444
- if (data.ok && data.element) {
445
- setInspectedData(data.element);
446
- if (data.history) setHistoryState(data.history);
447
- const ownerScreen =
448
- screens.find((s) => s.filePath === data.element!.file)?.id ??
449
- activeScreenId ??
450
- 'Home';
451
- setSelectedElement({
452
- screenId: ownerScreen,
453
- file: data.element.file,
454
- astPath: data.element.astPath,
455
- line: data.element.line,
456
- col: data.element.col,
457
- tagName: data.element.tagName,
458
- });
459
- setStatusMessage(`Added <${data.element.tagName}> to .tsx`);
460
- void fetchScreens();
461
- } else if (data.error) {
462
- setStatusMessage(`Error: ${data.error}`);
463
- }
464
- } catch {
465
- setStatusMessage('Failed to insert AST element');
466
- } finally {
467
- setIsSaving(false);
468
- }
469
- },
470
- [fetchScreens, screens, activeScreenId]
471
- );
472
-
473
- // Surgical AST Duplicate Element handler
474
- const handleDuplicateElement = useCallback(
475
- async (payload: DuplicateElementPayload) => {
476
- setIsSaving(true);
477
- setStatusMessage(null);
478
- try {
479
- const res = await fetch('/api/duplicate', {
480
- method: 'POST',
481
- headers: { 'Content-Type': 'application/json' },
482
- body: JSON.stringify(payload),
483
- });
484
- const data = (await res.json()) as {
485
- ok: boolean;
486
- element?: InspectedElement;
487
- history?: { undoAvailable: number; redoAvailable: number };
488
- error?: string;
489
- };
490
- if (data.ok && data.element) {
491
- setInspectedData(data.element);
492
- if (data.history) setHistoryState(data.history);
493
- setSelectedElement((prev) =>
494
- prev
495
- ? {
496
- ...prev,
497
- line: data.element!.line,
498
- col: data.element!.col,
499
- astPath: data.element!.astPath,
500
- tagName: data.element!.tagName,
501
- }
502
- : null
503
- );
504
- setStatusMessage('Duplicated element in .tsx');
505
- void fetchScreens();
506
- } else if (data.error) {
507
- setStatusMessage(`Error: ${data.error}`);
508
- }
509
- } catch {
510
- setStatusMessage('Failed to duplicate AST element');
511
- } finally {
512
- setIsSaving(false);
513
- }
514
- },
515
- [fetchScreens]
516
- );
517
-
518
- // Surgical AST Delete Element handler
519
- const handleDeleteElement = useCallback(
520
- async (payload: DeleteElementPayload) => {
521
- setIsSaving(true);
522
- setStatusMessage(null);
523
- try {
524
- const res = await fetch('/api/delete', {
525
- method: 'POST',
526
- headers: { 'Content-Type': 'application/json' },
527
- body: JSON.stringify(payload),
528
- });
529
- const data = (await res.json()) as {
530
- ok: boolean;
531
- deletedAstPath?: string;
532
- history?: { undoAvailable: number; redoAvailable: number };
533
- error?: string;
534
- };
535
- if (data.ok) {
536
- if (data.history) setHistoryState(data.history);
537
- setSelectedElement(null);
538
- setInspectedData(null);
539
- setStatusMessage('Deleted element from .tsx');
540
- void fetchScreens();
541
- } else if (data.error) {
542
- setStatusMessage(`Error: ${data.error}`);
543
- }
544
- } catch {
545
- setStatusMessage('Failed to delete AST element');
546
- } finally {
547
- setIsSaving(false);
548
- }
549
- },
550
- [fetchScreens]
551
- );
552
-
553
- // Undo & Redo handlers
554
- const handleUndo = useCallback(async () => {
555
- const res = await fetch('/api/undo', {
556
- method: 'POST',
557
- headers: { 'Content-Type': 'application/json' },
558
- body: '{}',
559
- });
560
- const data = (await res.json()) as {
561
- ok: boolean;
562
- restoredFile: string | null;
563
- undoAvailable: number;
564
- redoAvailable: number;
565
- };
566
- if (data.ok) {
567
- setHistoryState({
568
- undoAvailable: data.undoAvailable,
569
- redoAvailable: data.redoAvailable,
570
- });
571
- if (selectedElement) {
572
- void inspectSelectedElement(selectedElement);
573
- }
574
- void fetchScreens();
575
- setStatusMessage(data.restoredFile ? `Reverted ${data.restoredFile}` : 'Nothing to undo');
576
- }
577
- }, [selectedElement, inspectSelectedElement, fetchScreens]);
578
-
579
- const handleRedo = useCallback(async () => {
580
- const res = await fetch('/api/redo', {
581
- method: 'POST',
582
- headers: { 'Content-Type': 'application/json' },
583
- body: '{}',
584
- });
585
- const data = (await res.json()) as {
586
- ok: boolean;
587
- restoredFile: string | null;
588
- undoAvailable: number;
589
- redoAvailable: number;
590
- };
591
- if (data.ok) {
592
- setHistoryState({
593
- undoAvailable: data.undoAvailable,
594
- redoAvailable: data.redoAvailable,
595
- });
596
- if (selectedElement) {
597
- void inspectSelectedElement(selectedElement);
598
- }
599
- void fetchScreens();
600
- setStatusMessage(data.restoredFile ? `Restored ${data.restoredFile}` : 'Nothing to redo');
601
- }
602
- }, [selectedElement, inspectSelectedElement, fetchScreens]);
603
-
604
- // Global keyboard shortcuts (Cmd+Z, Cmd+Shift+Z, [ and ] for sidebars, Escape)
605
- useEffect(() => {
606
- const handleKeyDown = (e: KeyboardEvent) => {
607
- const target = e.target as HTMLElement | null;
608
- const isInputFocused =
609
- target &&
610
- (target.tagName === 'INPUT' ||
611
- target.tagName === 'TEXTAREA' ||
612
- target.tagName === 'SELECT');
613
-
614
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !isInputFocused) {
615
- e.preventDefault();
616
- if (e.shiftKey) {
617
- void handleRedo();
618
- } else {
619
- void handleUndo();
620
- }
621
- } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === '[') {
622
- e.preventDefault();
623
- setIsLeftPanelOpen((prev) => !prev);
624
- } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === ']') {
625
- e.preventDefault();
626
- setIsRightPanelOpen((prev) => !prev);
627
- } else if (
628
- !isInputFocused &&
629
- selectedElement &&
630
- inspectedData &&
631
- ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)
632
- ) {
633
- e.preventDefault();
634
- const step = e.shiftKey ? 8 : 1;
635
- const isAbs = inspectedData.styles.position === 'absolute';
636
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
637
- const prop = isAbs ? 'top' : 'marginTop';
638
- const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
639
- const nextVal = e.key === 'ArrowUp' ? currentVal - step : currentVal + step;
640
- void handlePatchElement({
641
- file: selectedElement.file,
642
- astPath: selectedElement.astPath,
643
- line: selectedElement.line,
644
- styles: { [prop]: nextVal },
645
- });
646
- } else {
647
- const prop = isAbs ? 'left' : 'marginLeft';
648
- const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
649
- const nextVal = e.key === 'ArrowLeft' ? currentVal - step : currentVal + step;
650
- void handlePatchElement({
651
- file: selectedElement.file,
652
- astPath: selectedElement.astPath,
653
- line: selectedElement.line,
654
- styles: { [prop]: nextVal },
655
- });
656
- }
657
- } else if (e.key === 'Escape') {
658
- setSelectedElement(null);
659
- setInspectedData(null);
660
- }
661
- };
662
-
663
- window.addEventListener('keydown', handleKeyDown);
664
- return () => window.removeEventListener('keydown', handleKeyDown);
665
- }, [handleUndo, handleRedo, selectedElement, inspectedData, handlePatchElement]);
666
-
667
- // Lock out whole-page browser pinch-to-zoom & route trackpad pinch to cursor-centered canvas zoom
668
- useEffect(() => {
669
- const viewport = canvasViewportRef.current;
670
-
671
- const applyFocalZoom = (nextZoomRaw: number, clientX: number, clientY: number) => {
672
- if (!viewport) return;
673
- const rect = viewport.getBoundingClientRect();
674
- const prevZoom = zoomRef.current;
675
- const prevPan = panRef.current;
676
- const nextZoom = clampZoom(nextZoomRaw);
677
-
678
- if (nextZoom === prevZoom) return;
679
-
680
- // Pin the world coordinate directly under the user's cursor during two-finger pinch
681
- // Keep full floating-point precision during active zoom gesture so (cursorX - worldX * nextZoom) never oscillates
682
- const cursorX = clientX - rect.left;
683
- const cursorY = clientY - rect.top;
684
- const worldX = (cursorX - prevPan.x) / prevZoom;
685
- const worldY = (cursorY - prevPan.y) / prevZoom;
686
-
687
- const nextPan = {
688
- x: cursorX - worldX * nextZoom,
689
- y: cursorY - worldY * nextZoom,
690
- };
691
-
692
- zoomRef.current = nextZoom;
693
- panRef.current = nextPan;
694
- if (worldContainerRef.current) {
695
- worldContainerRef.current.style.transition = 'none';
696
- worldContainerRef.current.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${nextZoom})`;
697
- }
698
- if (zoomLabelRef.current) {
699
- zoomLabelRef.current.textContent = `${Math.round(nextZoom * 100)}%`;
700
- }
701
- scheduleCameraStateSync(false);
702
- };
703
-
704
- // 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
705
- const handleCanvasWheel = (e: WheelEvent) => {
706
- e.preventDefault();
707
- e.stopPropagation();
708
-
709
- if (e.ctrlKey || e.metaKey) {
710
- // Trackpad two-finger pinch or Cmd/Ctrl + wheel
711
- const zoomFactor = Math.exp(-e.deltaY * 0.0085);
712
- applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
713
- } else {
714
- applyDirectPanTransform(
715
- panRef.current.x - e.deltaX,
716
- panRef.current.y - e.deltaY,
717
- false
718
- );
719
- }
720
- };
721
-
722
- // 2. Document-level non-passive guard so pinching over the top bar or sidebars NEVER zooms the browser page
723
- const handleDocumentWheelGuard = (e: WheelEvent) => {
724
- if (e.ctrlKey || e.metaKey) {
725
- e.preventDefault();
726
- }
727
- };
728
-
729
- // 3. WebKit/Safari proprietary trackpad gesture guard (gesturestart / gesturechange / gestureend)
730
- let gestureStartZoom = zoomRef.current;
731
- const handleGestureStart = (e: Event) => {
732
- e.preventDefault();
733
- gestureStartZoom = zoomRef.current;
734
- };
735
- const handleGestureChange = (e: Event) => {
736
- e.preventDefault();
737
- const gestureEvent = e as unknown as {
738
- scale?: number;
739
- clientX?: number;
740
- clientY?: number;
741
- };
742
- if (typeof gestureEvent.scale === 'number' && viewport) {
743
- const rect = viewport.getBoundingClientRect();
744
- const cx = gestureEvent.clientX ?? rect.left + rect.width / 2;
745
- const cy = gestureEvent.clientY ?? rect.top + rect.height / 2;
746
- applyFocalZoom(gestureStartZoom * gestureEvent.scale, cx, cy);
747
- }
748
- };
749
- const handleGestureEnd = (e: Event) => {
750
- e.preventDefault();
751
- };
752
-
753
- viewport?.addEventListener('wheel', handleCanvasWheel, { passive: false });
754
- document.addEventListener('wheel', handleDocumentWheelGuard, { passive: false });
755
- document.addEventListener('gesturestart', handleGestureStart, { passive: false });
756
- document.addEventListener('gesturechange', handleGestureChange, { passive: false });
757
- document.addEventListener('gestureend', handleGestureEnd, { passive: false });
758
-
759
- return () => {
760
- viewport?.removeEventListener('wheel', handleCanvasWheel);
761
- document.removeEventListener('wheel', handleDocumentWheelGuard);
762
- document.removeEventListener('gesturestart', handleGestureStart);
763
- document.removeEventListener('gesturechange', handleGestureChange);
764
- document.removeEventListener('gestureend', handleGestureEnd);
765
- };
766
- }, [applyDirectPanTransform, scheduleCameraStateSync]);
767
-
768
- // Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
769
- const focusOnScreen = useCallback(
770
- (screenId: string) => {
771
- const pos = layout[screenId];
772
- const viewport = canvasViewportRef.current;
773
- if (!pos || !viewport) return;
774
-
775
- const rect = viewport.getBoundingClientRect();
776
- const targetZoom = Math.min(Math.max(zoom, 0.72), 0.95);
777
- const nextPanX = rect.width / 2 - (pos.x + pos.width / 2) * targetZoom;
778
- const nextPanY = rect.height / 2 - (pos.y + pos.height / 2) * targetZoom;
779
-
780
- triggerCameraTransition();
781
- setZoom(targetZoom);
782
- setPan({ x: Math.round(nextPanX), y: Math.round(nextPanY) });
783
- setActiveScreenId(screenId);
784
- setHighlightedScreenId(screenId);
785
- window.setTimeout(() => setHighlightedScreenId(null), 1600);
786
- },
787
- [layout, zoom, triggerCameraTransition]
788
- );
789
-
790
- // Fit all screens in viewport
791
- const fitCameraToLayout = useCallback(
792
- (targetLayout: ArtboardLayoutMap) => {
793
- const viewport = canvasViewportRef.current;
794
- if (!viewport) return;
795
- const rect = viewport.getBoundingClientRect();
796
-
797
- const positions = Object.values(targetLayout);
798
- if (positions.length === 0) return;
799
-
800
- const minX = Math.min(...positions.map((p) => p.x));
801
- const minY = Math.min(...positions.map((p) => p.y));
802
- const maxX = Math.max(...positions.map((p) => p.x + p.width));
803
- const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
804
-
805
- const contentWidth = maxX - minX + 140;
806
- const contentHeight = maxY - minY + 140;
807
-
808
- const scaleX = rect.width / contentWidth;
809
- const scaleY = rect.height / contentHeight;
810
- const nextZoom = clampZoom(Math.min(scaleX, scaleY, 1.0));
811
-
812
- const centerX = (minX + maxX) / 2;
813
- const centerY = (minY + maxY) / 2;
814
- const nextPan = {
815
- x: Math.round(rect.width / 2 - centerX * nextZoom),
816
- y: Math.round(rect.height / 2 - centerY * nextZoom),
817
- };
818
-
819
- triggerCameraTransition();
820
- zoomRef.current = nextZoom;
821
- panRef.current = nextPan;
822
- setZoom(nextZoom);
823
- setPan(nextPan);
824
- },
825
- [triggerCameraTransition]
826
- );
827
-
828
- const handleFitAllScreens = useCallback(() => {
829
- fitCameraToLayout(layout);
830
- }, [fitCameraToLayout, layout]);
831
-
832
- // Reset layout to clean multi-row grid and fit in viewport
833
- const handleResetGridLayout = useCallback(() => {
834
- const reset = buildDefaultLayout(screens);
835
- setLayout(reset);
836
- fitCameraToLayout(reset);
837
- void fetch('/api/layout', {
838
- method: 'POST',
839
- headers: { 'Content-Type': 'application/json' },
840
- body: JSON.stringify(reset),
841
- });
842
- }, [screens, fitCameraToLayout]);
843
-
844
- // Move single artboard
845
- const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
846
- setLayout((prev) => {
847
- const current = prev[screenId] ?? {
848
- x: nextX,
849
- y: nextY,
850
- width: DEFAULT_WIDTH,
851
- height: DEFAULT_HEIGHT,
852
- };
853
- const updated = {
854
- ...prev,
855
- [screenId]: { ...current, x: nextX, y: nextY },
856
- };
857
- return updated;
858
- });
859
- }, []);
860
-
861
- // Handle interactive navigation inside a React Native screen
862
- const handleNavigateFlow = useCallback(
863
- (_fromScreen: string, toScreen: string) => {
864
- focusOnScreen(toScreen);
865
- },
866
- [focusOnScreen]
867
- );
868
-
869
- const handleSelectFlow = useCallback(
870
- (edge: NavigationEdge) => {
871
- focusOnScreen(edge.toScreen);
872
- },
873
- [focusOnScreen]
874
- );
875
-
876
- // Canvas background pointer pan (0ms direct DOM manipulation override)
877
- const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
878
- if (e.button !== 0 && e.button !== 1) return;
879
- setIsCameraAnimating(false);
880
- setIsPanning(true);
881
-
882
- const startX = e.clientX;
883
- const startY = e.clientY;
884
- const startPanX = panRef.current.x;
885
- const startPanY = panRef.current.y;
886
-
887
- const handleMove = (moveEvent: PointerEvent) => {
888
- applyDirectPanTransform(
889
- startPanX + (moveEvent.clientX - startX),
890
- startPanY + (moveEvent.clientY - startY),
891
- false
892
- );
893
- };
894
-
895
- const handleUp = () => {
896
- setIsPanning(false);
897
- applyDirectPanTransform(panRef.current.x, panRef.current.y, true);
898
- window.removeEventListener('pointermove', handleMove);
899
- window.removeEventListener('pointerup', handleUp);
900
- };
901
-
902
- window.addEventListener('pointermove', handleMove);
903
- window.addEventListener('pointerup', handleUp);
904
- };
905
-
906
- return (
907
- <div
908
- className="zelune-studio-root"
909
- data-density="spacious"
910
- style={{
911
- display: 'flex',
912
- flexDirection: 'column',
913
- width: '100vw',
914
- height: '100vh',
915
- backgroundColor: 'var(--studio-canvas-bg)',
916
- color: 'var(--studio-text-primary)',
917
- overflow: 'hidden',
918
- }}
919
- >
920
- {/* Top Studio Bar */}
921
- <header
922
- style={{
923
- height: 52,
924
- backgroundColor: 'var(--studio-surface-1)',
925
- borderBottom: '1px solid var(--studio-border-hairline)',
926
- display: 'flex',
927
- alignItems: 'center',
928
- justifyContent: 'space-between',
929
- padding: '0 14px',
930
- zIndex: 40,
931
- }}
932
- >
933
- {/* Left: Sidebar Toggle + Architectural Brand Identity */}
934
- <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
935
- <button
936
- type="button"
937
- className="zelune-interactive"
938
- onClick={() => setIsLeftPanelOpen((prev) => !prev)}
939
- title={`${isLeftPanelOpen ? 'Collapse' : 'Expand'} Screens Sidebar ([)`}
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
- isLeftPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
949
- }`,
950
- backgroundColor: isLeftPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
951
- color: isLeftPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
952
- cursor: 'pointer',
953
- }}
954
- >
955
- {isLeftPanelOpen ? <PanelLeftClose size={14} /> : <PanelLeftOpen size={14} />}
956
- </button>
957
-
958
- <div
959
- style={{
960
- width: 30,
961
- height: 30,
962
- borderRadius: 7,
963
- backgroundColor: 'var(--studio-surface-2)',
964
- border: '1px solid rgba(56, 189, 248, 0.35)',
965
- boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
966
- display: 'flex',
967
- alignItems: 'center',
968
- justifyContent: 'center',
969
- fontFamily: 'var(--font-mono)',
970
- fontWeight: 700,
971
- fontSize: 12,
972
- letterSpacing: '-0.03em',
973
- color: '#38BDF8',
974
- }}
975
- >
976
- ZL
977
- </div>
978
- <div>
979
- <div
980
- style={{
981
- fontSize: 13.5,
982
- fontWeight: 700,
983
- letterSpacing: '-0.02em',
984
- color: 'var(--studio-text-primary)',
985
- }}
986
- >
987
- Zelune
988
- </div>
989
- <div
990
- style={{
991
- fontSize: 11,
992
- color: 'var(--studio-text-secondary)',
993
- fontFamily: 'var(--font-mono)',
994
- marginTop: 1,
995
- }}
996
- >
997
- {projectName}
998
- </div>
999
- </div>
1000
- </div>
1001
-
1002
- {/* Center: Icon-First Mode Switcher & Flow Toggle (Reveals Label on Hover) */}
1003
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1004
- <div
1005
- style={{
1006
- display: 'flex',
1007
- alignItems: 'center',
1008
- gap: 2,
1009
- backgroundColor: 'var(--studio-canvas-bg)',
1010
- padding: 3,
1011
- borderRadius: 8,
1012
- border: '1px solid var(--studio-border-subtle)',
1013
- }}
1014
- >
1015
- <button
1016
- type="button"
1017
- className="zelune-interactive zelune-icon-reveal-btn"
1018
- onClick={() => setMode('inspect')}
1019
- title="Inspect & Edit"
1020
- aria-label="Inspect & Edit"
1021
- style={{
1022
- padding: '6px 9px',
1023
- borderRadius: 5,
1024
- border: mode === 'inspect' ? '1px solid rgba(56, 189, 248, 0.35)' : '1px solid transparent',
1025
- backgroundColor: mode === 'inspect' ? 'var(--studio-surface-3)' : 'transparent',
1026
- color: mode === 'inspect' ? '#0284C7' : 'var(--studio-text-secondary)',
1027
- fontSize: 12,
1028
- fontWeight: 600,
1029
- cursor: 'pointer',
1030
- }}
1031
- >
1032
- <MousePointer size={14} style={{ flexShrink: 0, color: mode === 'inspect' ? '#38BDF8' : 'currentColor' }} />
1033
- <span className="zelune-reveal-label" style={{ color: mode === 'inspect' ? 'var(--studio-text-primary)' : 'inherit' }}>
1034
- Inspect &amp; Edit
1035
- </span>
1036
- </button>
1037
-
1038
- <button
1039
- type="button"
1040
- className="zelune-interactive zelune-icon-reveal-btn"
1041
- onClick={() => setMode('interact')}
1042
- title="Interact & Navigate"
1043
- aria-label="Interact & Navigate"
1044
- style={{
1045
- padding: '6px 9px',
1046
- borderRadius: 5,
1047
- border: mode === 'interact' ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid transparent',
1048
- backgroundColor: mode === 'interact' ? 'rgba(16, 185, 129, 0.14)' : 'transparent',
1049
- color: mode === 'interact' ? (colorScheme === 'dark' ? '#34D399' : '#059669') : 'var(--studio-text-secondary)',
1050
- fontSize: 12,
1051
- fontWeight: 600,
1052
- cursor: 'pointer',
1053
- }}
1054
- >
1055
- <Play size={14} style={{ flexShrink: 0 }} />
1056
- <span className="zelune-reveal-label">Interact &amp; Navigate</span>
1057
- </button>
1058
- </div>
1059
-
1060
- <button
1061
- type="button"
1062
- className="zelune-interactive zelune-icon-reveal-btn"
1063
- onClick={() => setShowFlows((prev) => !prev)}
1064
- title="Toggle Flow Graph"
1065
- aria-label="Toggle Flow Graph"
1066
- style={{
1067
- padding: '7px 9px',
1068
- borderRadius: 7,
1069
- border: `1px solid ${showFlows ? 'rgba(99, 102, 241, 0.45)' : 'var(--studio-border-subtle)'}`,
1070
- backgroundColor: showFlows ? 'rgba(99, 102, 241, 0.14)' : 'var(--studio-surface-2)',
1071
- color: showFlows ? (colorScheme === 'dark' ? '#C7D2FE' : '#4F46E5') : 'var(--studio-text-secondary)',
1072
- fontSize: 12,
1073
- fontWeight: 600,
1074
- cursor: 'pointer',
1075
- }}
1076
- >
1077
- <GitCommit size={14} style={{ flexShrink: 0 }} />
1078
- <span className="zelune-reveal-label">Flow Graph</span>
1079
- </button>
1080
-
1081
- <button
1082
- type="button"
1083
- className="zelune-interactive"
1084
- onClick={() => {
1085
- setIsRightPanelOpen(true);
1086
- window.dispatchEvent(new CustomEvent('zelune:open-insert-palette'));
1087
- }}
1088
- title="Add Button, Section, Input, or Component to Active Screen"
1089
- style={{
1090
- display: 'inline-flex',
1091
- alignItems: 'center',
1092
- gap: 5,
1093
- padding: '6px 10px',
1094
- borderRadius: 7,
1095
- border: '1px solid rgba(56, 189, 248, 0.4)',
1096
- backgroundColor: 'rgba(56, 189, 248, 0.12)',
1097
- color: colorScheme === 'dark' ? '#38BDF8' : '#0284C7',
1098
- fontSize: 12,
1099
- fontWeight: 600,
1100
- cursor: 'pointer',
1101
- }}
1102
- >
1103
- <Plus size={14} style={{ flexShrink: 0 }} />
1104
- <span>Add Element</span>
1105
- </button>
1106
- </div>
1107
-
1108
- {/* Right: Undo/Redo, Viewport Controls, Theme Toggle & Right Inspector Toggle */}
1109
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1110
- <button
1111
- type="button"
1112
- className="zelune-interactive tabular-nums"
1113
- onClick={handleUndo}
1114
- disabled={historyState.undoAvailable === 0}
1115
- title="Undo AST Patch (Cmd+Z)"
1116
- style={{
1117
- display: 'inline-flex',
1118
- alignItems: 'center',
1119
- gap: 4,
1120
- padding: '6px 9px',
1121
- borderRadius: 6,
1122
- border: '1px solid var(--studio-border-subtle)',
1123
- backgroundColor: 'var(--studio-surface-2)',
1124
- color:
1125
- historyState.undoAvailable > 0
1126
- ? 'var(--studio-text-primary)'
1127
- : 'var(--studio-text-muted)',
1128
- fontSize: 11.5,
1129
- fontFamily: 'var(--font-mono)',
1130
- cursor: historyState.undoAvailable > 0 ? 'pointer' : 'not-allowed',
1131
- }}
1132
- >
1133
- <Undo2 size={13} />
1134
- <span>{historyState.undoAvailable}</span>
1135
- </button>
1136
-
1137
- <button
1138
- type="button"
1139
- className="zelune-interactive"
1140
- onClick={handleRedo}
1141
- disabled={historyState.redoAvailable === 0}
1142
- title="Redo AST Patch (Cmd+Shift+Z)"
1143
- style={{
1144
- display: 'inline-flex',
1145
- alignItems: 'center',
1146
- gap: 4,
1147
- padding: '6px 9px',
1148
- borderRadius: 6,
1149
- border: '1px solid var(--studio-border-subtle)',
1150
- backgroundColor: 'var(--studio-surface-2)',
1151
- color:
1152
- historyState.redoAvailable > 0
1153
- ? 'var(--studio-text-primary)'
1154
- : 'var(--studio-text-muted)',
1155
- fontSize: 11.5,
1156
- cursor: historyState.redoAvailable > 0 ? 'pointer' : 'not-allowed',
1157
- }}
1158
- >
1159
- <Redo2 size={13} />
1160
- </button>
1161
-
1162
- <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
1163
-
1164
- <button
1165
- type="button"
1166
- className="zelune-interactive"
1167
- onClick={() => {
1168
- triggerCameraTransition();
1169
- setZoom((z) => clampZoom(z - 0.1));
1170
- }}
1171
- title="Zoom Out"
1172
- style={{
1173
- padding: 6,
1174
- borderRadius: 6,
1175
- border: '1px solid var(--studio-border-subtle)',
1176
- backgroundColor: 'var(--studio-surface-2)',
1177
- color: 'var(--studio-text-secondary)',
1178
- cursor: 'pointer',
1179
- }}
1180
- >
1181
- <ZoomOut size={13} />
1182
- </button>
1183
-
1184
- <span
1185
- ref={zoomLabelRef}
1186
- className="tabular-nums"
1187
- style={{
1188
- minWidth: 46,
1189
- textAlign: 'center',
1190
- fontSize: 11.5,
1191
- fontFamily: 'var(--font-mono)',
1192
- color: 'var(--studio-text-secondary)',
1193
- }}
1194
- >
1195
- {Math.round(zoom * 100)}%
1196
- </span>
1197
-
1198
- <button
1199
- type="button"
1200
- className="zelune-interactive"
1201
- onClick={() => {
1202
- triggerCameraTransition();
1203
- setZoom((z) => clampZoom(z + 0.1));
1204
- }}
1205
- title="Zoom In"
1206
- style={{
1207
- padding: 6,
1208
- borderRadius: 6,
1209
- border: '1px solid var(--studio-border-subtle)',
1210
- backgroundColor: 'var(--studio-surface-2)',
1211
- color: 'var(--studio-text-secondary)',
1212
- cursor: 'pointer',
1213
- }}
1214
- >
1215
- <ZoomIn size={13} />
1216
- </button>
1217
-
1218
- <button
1219
- type="button"
1220
- className="zelune-interactive zelune-icon-reveal-btn"
1221
- onClick={handleFitAllScreens}
1222
- title="Fit All Screens in Viewport"
1223
- aria-label="Fit All Screens in Viewport"
1224
- style={{
1225
- padding: '6px 8px',
1226
- borderRadius: 6,
1227
- border: '1px solid var(--studio-border-subtle)',
1228
- backgroundColor: 'var(--studio-surface-2)',
1229
- color: 'var(--studio-text-secondary)',
1230
- fontSize: 11.5,
1231
- fontWeight: 500,
1232
- cursor: 'pointer',
1233
- }}
1234
- >
1235
- <Maximize size={13} style={{ flexShrink: 0 }} />
1236
- <span className="zelune-reveal-label">Fit</span>
1237
- </button>
1238
-
1239
- <button
1240
- type="button"
1241
- className="zelune-interactive zelune-icon-reveal-btn"
1242
- onClick={handleResetGridLayout}
1243
- title="Auto-Arrange Clean Artboard Grid"
1244
- aria-label="Auto-Arrange Clean Artboard Grid"
1245
- style={{
1246
- padding: '6px 8px',
1247
- borderRadius: 6,
1248
- border: '1px solid var(--studio-border-subtle)',
1249
- backgroundColor: 'var(--studio-surface-2)',
1250
- color: 'var(--studio-text-secondary)',
1251
- fontSize: 11.5,
1252
- fontWeight: 500,
1253
- cursor: 'pointer',
1254
- }}
1255
- >
1256
- <LayoutGrid size={13} style={{ flexShrink: 0 }} />
1257
- <span className="zelune-reveal-label">Grid</span>
1258
- </button>
1259
-
1260
- <button
1261
- type="button"
1262
- className="zelune-interactive zelune-icon-reveal-btn"
1263
- onClick={toggleColorScheme}
1264
- title={
1265
- colorScheme === 'dark' ? 'Switch Canvas to Light Mode' : 'Switch Canvas to Dark Mode'
1266
- }
1267
- aria-label={
1268
- colorScheme === 'dark' ? 'Switch Canvas to Light Mode' : 'Switch Canvas to Dark Mode'
1269
- }
1270
- style={{
1271
- padding: '6px 8px',
1272
- borderRadius: 6,
1273
- border: '1px solid var(--studio-border-subtle)',
1274
- backgroundColor: 'var(--studio-surface-2)',
1275
- color: 'var(--studio-text-secondary)',
1276
- fontSize: 11.5,
1277
- fontWeight: 500,
1278
- cursor: 'pointer',
1279
- }}
1280
- >
1281
- {colorScheme === 'dark' ? (
1282
- <Sun size={13} style={{ flexShrink: 0, color: '#F59E0B' }} />
1283
- ) : (
1284
- <Moon size={13} style={{ flexShrink: 0, color: '#6366F1' }} />
1285
- )}
1286
- <span className="zelune-reveal-label">
1287
- {colorScheme === 'dark' ? 'Light' : 'Dark'}
1288
- </span>
1289
- </button>
1290
-
1291
- <div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
1292
-
1293
- <button
1294
- type="button"
1295
- className="zelune-interactive"
1296
- onClick={() => setIsRightPanelOpen((prev) => !prev)}
1297
- title={`${isRightPanelOpen ? 'Collapse' : 'Expand'} AST Inspector Panel (])`}
1298
- style={{
1299
- display: 'inline-flex',
1300
- alignItems: 'center',
1301
- justifyContent: 'center',
1302
- width: 30,
1303
- height: 30,
1304
- borderRadius: 7,
1305
- border: `1px solid ${
1306
- isRightPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
1307
- }`,
1308
- backgroundColor: isRightPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
1309
- color: isRightPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
1310
- cursor: 'pointer',
1311
- }}
1312
- >
1313
- {isRightPanelOpen ? <PanelRightClose size={14} /> : <PanelRightOpen size={14} />}
1314
- </button>
1315
- </div>
1316
- </header>
1317
-
1318
- {/* Main Studio Body: Collapsible Left Screen Rail + Infinite Canvas + Collapsible Right AST Inspector */}
1319
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
1320
- {/* Left Sidebar: Screen Artboard Navigator */}
1321
- <aside
1322
- aria-hidden={!isLeftPanelOpen}
1323
- className="zelune-collapsible-panel"
1324
- style={{
1325
- width: isLeftPanelOpen ? 224 : 0,
1326
- opacity: isLeftPanelOpen ? 1 : 0,
1327
- pointerEvents: isLeftPanelOpen ? 'auto' : 'none',
1328
- backgroundColor: 'var(--studio-surface-1)',
1329
- borderRight: isLeftPanelOpen
1330
- ? '1px solid var(--studio-border-hairline)'
1331
- : '0px solid transparent',
1332
- display: 'flex',
1333
- flexDirection: 'column',
1334
- zIndex: 30,
1335
- }}
1336
- >
1337
- <div
1338
- className="zelune-scroller"
1339
- style={{
1340
- width: 224,
1341
- minWidth: 224,
1342
- height: '100%',
1343
- display: 'flex',
1344
- flexDirection: 'column',
1345
- justifyContent: 'space-between',
1346
- padding: 12,
1347
- overflowY: 'auto',
1348
- }}
1349
- >
1350
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1351
- <div
1352
- className="tabular-nums"
1353
- style={{
1354
- display: 'flex',
1355
- alignItems: 'center',
1356
- justifyContent: 'space-between',
1357
- padding: '4px 6px 6px',
1358
- fontSize: 10.5,
1359
- fontFamily: 'var(--font-mono)',
1360
- fontWeight: 600,
1361
- color: 'var(--studio-text-muted)',
1362
- letterSpacing: '0.06em',
1363
- }}
1364
- >
1365
- <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1366
- <Layers size={12} color="#38BDF8" />
1367
- <span>SCREENS ({screens.length})</span>
1368
- </span>
1369
- <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
1370
- <button
1371
- type="button"
1372
- className="zelune-interactive"
1373
- onClick={() => void fetchScreens()}
1374
- title="Refresh Screen Discovery"
1375
- style={{
1376
- background: 'transparent',
1377
- border: '1px solid transparent',
1378
- borderRadius: 4,
1379
- color: 'var(--studio-text-muted)',
1380
- cursor: 'pointer',
1381
- padding: 3,
1382
- display: 'inline-flex',
1383
- alignItems: 'center',
1384
- }}
1385
- >
1386
- <RefreshCw size={11} />
1387
- </button>
1388
- <button
1389
- type="button"
1390
- className="zelune-interactive"
1391
- onClick={() => setIsLeftPanelOpen(false)}
1392
- title="Collapse Screens Sidebar ([)"
1393
- style={{
1394
- background: 'transparent',
1395
- border: '1px solid transparent',
1396
- borderRadius: 4,
1397
- color: 'var(--studio-text-muted)',
1398
- cursor: 'pointer',
1399
- padding: 3,
1400
- display: 'inline-flex',
1401
- alignItems: 'center',
1402
- }}
1403
- >
1404
- <PanelLeftClose size={12} />
1405
- </button>
1406
- </div>
1407
- </div>
1408
-
1409
- {screens.map((scr) => {
1410
- const isSelected = activeScreenId === scr.id;
1411
- return (
1412
- <button
1413
- key={scr.id}
1414
- type="button"
1415
- className="zelune-interactive zelune-rail-item"
1416
- onClick={() => focusOnScreen(scr.id)}
1417
- style={{
1418
- display: 'flex',
1419
- alignItems: 'center',
1420
- justifyContent: 'space-between',
1421
- width: '100%',
1422
- paddingLeft: 10,
1423
- paddingRight: 10,
1424
- borderRadius: 7,
1425
- border: `1px solid ${
1426
- isSelected ? 'rgba(56, 189, 248, 0.35)' : 'transparent'
1427
- }`,
1428
- backgroundColor: isSelected ? 'var(--studio-surface-2)' : 'transparent',
1429
- color: isSelected ? 'var(--studio-text-primary)' : 'var(--studio-text-secondary)',
1430
- fontSize: 12.5,
1431
- fontWeight: isSelected ? 600 : 500,
1432
- cursor: 'pointer',
1433
- textAlign: 'left',
1434
- }}
1435
- >
1436
- <span>{scr.id}</span>
1437
- <span
1438
- className="tabular-nums"
1439
- style={{
1440
- fontSize: 10,
1441
- fontFamily: 'var(--font-mono)',
1442
- color: isSelected ? '#38BDF8' : 'var(--studio-text-muted)',
1443
- }}
1444
- >
1445
- {scr.outgoingFlows.length}→
1446
- </span>
1447
- </button>
1448
- );
1449
- })}
1450
- </div>
1451
-
1452
- <div
1453
- style={{
1454
- backgroundColor: 'var(--studio-surface-2)',
1455
- border: '1px solid var(--studio-border-hairline)',
1456
- borderRadius: 9,
1457
- padding: 11,
1458
- fontSize: 11,
1459
- color: 'var(--studio-text-secondary)',
1460
- lineHeight: 1.55,
1461
- }}
1462
- >
1463
- <div
1464
- style={{
1465
- fontFamily: 'var(--font-mono)',
1466
- fontSize: 10,
1467
- fontWeight: 600,
1468
- letterSpacing: '0.05em',
1469
- color: 'var(--studio-text-primary)',
1470
- marginBottom: 4,
1471
- }}
1472
- >
1473
- STUDIO ERGONOMICS
1474
- </div>
1475
- <div>• Two-finger scroll to pan</div>
1476
- <div>• Two-finger pinch to zoom</div>
1477
- <div>• Press [ or ] to toggle panels</div>
1478
- <div>• Click any node to patch AST</div>
1479
- </div>
1480
- </div>
1481
- </aside>
1482
-
1483
- {/* Center: Infinite Zoom/Pan Canvas */}
1484
- <main
1485
- ref={canvasViewportRef}
1486
- onPointerDown={handleCanvasPointerDown}
1487
- className="infinite-canvas-surface"
1488
- style={{
1489
- flex: 1,
1490
- position: 'relative',
1491
- overflow: 'hidden',
1492
- cursor: isPanning ? 'grabbing' : 'grab',
1493
- }}
1494
- >
1495
- {/* Floating Edge Restore Pill for Collapsed Left Sidebar */}
1496
- {!isLeftPanelOpen && (
1497
- <button
1498
- type="button"
1499
- className="zelune-interactive tabular-nums"
1500
- onClick={(e) => {
1501
- e.stopPropagation();
1502
- setIsLeftPanelOpen(true);
1503
- }}
1504
- onPointerDown={(e) => e.stopPropagation()}
1505
- title="Expand Screens Navigator ([)"
1506
- style={{
1507
- position: 'absolute',
1508
- top: 14,
1509
- left: 14,
1510
- zIndex: 35,
1511
- display: 'inline-flex',
1512
- alignItems: 'center',
1513
- gap: 7,
1514
- padding: '7px 11px',
1515
- borderRadius: 8,
1516
- backgroundColor: 'var(--studio-surface-1)',
1517
- border: '1px solid var(--studio-border-subtle)',
1518
- color: 'var(--studio-text-primary)',
1519
- fontSize: 11.5,
1520
- fontFamily: 'var(--font-mono)',
1521
- fontWeight: 600,
1522
- cursor: 'pointer',
1523
- boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.28)',
1524
- }}
1525
- >
1526
- <PanelLeftOpen size={13} color="#38BDF8" />
1527
- <span>Screens ({screens.length})</span>
1528
- </button>
1529
- )}
1530
-
1531
- {/* Floating Edge Restore Pill for Collapsed Right Inspector */}
1532
- {!isRightPanelOpen && (
1533
- <button
1534
- type="button"
1535
- className="zelune-interactive tabular-nums"
1536
- onClick={(e) => {
1537
- e.stopPropagation();
1538
- setIsRightPanelOpen(true);
1539
- }}
1540
- onPointerDown={(e) => e.stopPropagation()}
1541
- title="Expand AST Visual Inspector (])"
1542
- style={{
1543
- position: 'absolute',
1544
- top: 14,
1545
- right: 14,
1546
- zIndex: 35,
1547
- display: 'inline-flex',
1548
- alignItems: 'center',
1549
- gap: 7,
1550
- padding: '7px 11px',
1551
- borderRadius: 8,
1552
- backgroundColor: 'var(--studio-surface-1)',
1553
- border: '1px solid var(--studio-border-subtle)',
1554
- color: 'var(--studio-text-primary)',
1555
- fontSize: 11.5,
1556
- fontFamily: 'var(--font-mono)',
1557
- fontWeight: 600,
1558
- cursor: 'pointer',
1559
- boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.28)',
1560
- }}
1561
- >
1562
- <Sliders size={13} color="#38BDF8" />
1563
- <span>
1564
- {inspectedData ? `<${inspectedData.tagName}> L${inspectedData.line}` : 'AST Inspector'}
1565
- </span>
1566
- <PanelRightOpen size={13} color="var(--studio-text-secondary)" />
1567
- </button>
1568
- )}
1569
-
1570
- {/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
1571
- <div
1572
- ref={worldContainerRef}
1573
- style={{
1574
- position: 'absolute',
1575
- top: 0,
1576
- left: 0,
1577
- transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
1578
- transformOrigin: '0 0',
1579
- transition: isCameraAnimating
1580
- ? 'transform var(--duration-camera) var(--ease-out-expo)'
1581
- : 'none',
1582
- }}
1583
- >
1584
- {showFlows && (
1585
- <FlowArrowsLayer
1586
- screens={screens}
1587
- layout={layout}
1588
- activeScreenId={activeScreenId}
1589
- highlightedScreenId={highlightedScreenId}
1590
- onSelectFlow={handleSelectFlow}
1591
- />
1592
- )}
1593
-
1594
- {screens.map((screen) => {
1595
- const pos = layout[screen.id] ?? {
1596
- x: INITIAL_OFFSET_X,
1597
- y: INITIAL_OFFSET_Y,
1598
- width: DEFAULT_WIDTH,
1599
- height: DEFAULT_HEIGHT,
1600
- };
1601
- const ScreenComponent = SCREEN_COMPONENTS[screen.id];
1602
- const isSelectedScreen = selectedElement?.screenId === screen.id;
1603
-
1604
- return (
1605
- <ScreenArtboard
1606
- key={screen.id}
1607
- screen={screen}
1608
- ScreenComponent={ScreenComponent}
1609
- position={pos}
1610
- zoom={zoom}
1611
- mode={mode}
1612
- isActiveScreen={activeScreenId === screen.id}
1613
- isHighlightedScreen={highlightedScreenId === screen.id}
1614
- isCanvasMoving={isPanning}
1615
- selectedElement={isSelectedScreen ? selectedElement : null}
1616
- inspectedElement={isSelectedScreen ? inspectedData : null}
1617
- onSelectElement={handleSelectElement}
1618
- onSelectScreen={setActiveScreenId}
1619
- onFocusScreen={focusOnScreen}
1620
- onMoveArtboard={handleMoveArtboard}
1621
- onNavigateFlow={handleNavigateFlow}
1622
- onPatchElement={handlePatchElement}
1623
- onReorderElement={handleReorderElement}
1624
- />
1625
- );
1626
- })}
1627
- </div>
1628
- </main>
1629
-
1630
- {/* Right: AST Visual Inspector Panel */}
1631
- <InspectorPanel
1632
- inspected={inspectedData}
1633
- screens={screens}
1634
- activeScreenId={activeScreenId}
1635
- isOpen={isRightPanelOpen}
1636
- isLoading={isInspecting}
1637
- isSaving={isSaving}
1638
- statusMessage={statusMessage}
1639
- onPatch={handlePatchElement}
1640
- onInsert={handleInsertElement}
1641
- onDuplicate={handleDuplicateElement}
1642
- onDelete={handleDeleteElement}
1643
- onReorder={handleReorderElement}
1644
- onSelectScreen={setActiveScreenId}
1645
- onClearSelection={() => {
1646
- setSelectedElement(null);
1647
- setInspectedData(null);
1648
- }}
1649
- onCollapse={() => setIsRightPanelOpen(false)}
1650
- onJumpToScreen={focusOnScreen}
1651
- />
1652
- </div>
1653
- </div>
1654
- );
1655
- };