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.
- package/CHANGELOG.md +57 -0
- package/DESIGN.md +90 -0
- package/PRODUCT.md +31 -0
- package/README.md +63 -2
- package/assets/logo.svg +8 -0
- package/bin/cli.mjs +243 -0
- package/index.html +18 -0
- package/mcp/server.ts +140 -0
- package/mcp_config.json +8 -0
- package/package.json +77 -4
- package/plugin.json +12 -0
- package/plugins/viteRnSourceInject.ts +117 -0
- package/plugins/viteVirtualScreensPlugin.ts +85 -0
- package/rules/AGENTS.md +21 -0
- package/skills/rn-canvas/SKILL.md +33 -0
- package/src/canvas/CanvasApp.tsx +1203 -0
- package/src/canvas/FlowArrowsLayer.tsx +197 -0
- package/src/canvas/InspectorPanel.tsx +979 -0
- package/src/canvas/ScreenArtboard.tsx +451 -0
- package/src/canvas/types.ts +23 -0
- package/src/main.tsx +10 -0
- package/src/runtime/MockNavigationContext.tsx +199 -0
- package/src/runtime/ScreenErrorBoundary.tsx +86 -0
- package/src/screens/CheckoutScreen.tsx +229 -0
- package/src/screens/HomeScreen.tsx +288 -0
- package/src/screens/LoginScreen.tsx +176 -0
- package/src/screens/ProductDetailScreen.tsx +234 -0
- package/src/screens/ProfileScreen.tsx +172 -0
- package/src/screens/index.ts +1 -0
- package/src/server/apiMiddleware.ts +154 -0
- package/src/server/astEngine.ts +877 -0
- package/src/styles.css +298 -0
- package/src/vite-env.d.ts +7 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +81 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { GripHorizontal, Maximize2, AlertTriangle } from 'lucide-react';
|
|
3
|
+
import { MockNavigationProvider } from '../runtime/MockNavigationContext';
|
|
4
|
+
import { ScreenErrorBoundary } from '../runtime/ScreenErrorBoundary';
|
|
5
|
+
import type {
|
|
6
|
+
ArtboardPosition,
|
|
7
|
+
CanvasMode,
|
|
8
|
+
ScreenMetadata,
|
|
9
|
+
SelectedElementRef,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
interface ScreenArtboardProps {
|
|
13
|
+
screen: ScreenMetadata;
|
|
14
|
+
ScreenComponent: React.ComponentType | undefined;
|
|
15
|
+
position: ArtboardPosition;
|
|
16
|
+
zoom: number;
|
|
17
|
+
mode: CanvasMode;
|
|
18
|
+
isActiveScreen: boolean;
|
|
19
|
+
isHighlightedScreen: boolean;
|
|
20
|
+
selectedElement: SelectedElementRef | null;
|
|
21
|
+
onSelectElement: (ref: SelectedElementRef) => void;
|
|
22
|
+
onSelectScreen: (screenId: string) => void;
|
|
23
|
+
onFocusScreen: (screenId: string) => void;
|
|
24
|
+
onMoveArtboard: (screenId: string, nextX: number, nextY: number) => void;
|
|
25
|
+
onNavigateFlow: (fromScreen: string, toScreen: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BoxOverlay {
|
|
29
|
+
top: number;
|
|
30
|
+
left: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
tagName: string;
|
|
34
|
+
astPath: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findNearestAgyElement(
|
|
38
|
+
target: EventTarget | null,
|
|
39
|
+
container: HTMLElement | null
|
|
40
|
+
): HTMLElement | null {
|
|
41
|
+
if (!target || !(target instanceof HTMLElement) || !container) return null;
|
|
42
|
+
let curr: HTMLElement | null = target;
|
|
43
|
+
while (curr && curr !== container && container.contains(curr)) {
|
|
44
|
+
if (curr.dataset?.agyAstPath && curr.dataset?.agyFile) {
|
|
45
|
+
return curr;
|
|
46
|
+
}
|
|
47
|
+
curr = curr.parentElement;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
53
|
+
screen,
|
|
54
|
+
ScreenComponent,
|
|
55
|
+
position,
|
|
56
|
+
zoom,
|
|
57
|
+
mode,
|
|
58
|
+
isActiveScreen,
|
|
59
|
+
isHighlightedScreen,
|
|
60
|
+
selectedElement,
|
|
61
|
+
onSelectElement,
|
|
62
|
+
onSelectScreen,
|
|
63
|
+
onFocusScreen,
|
|
64
|
+
onMoveArtboard,
|
|
65
|
+
onNavigateFlow,
|
|
66
|
+
}) => {
|
|
67
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const [hoverBox, setHoverBox] = useState<BoxOverlay | null>(null);
|
|
69
|
+
const [selectedBox, setSelectedBox] = useState<BoxOverlay | null>(null);
|
|
70
|
+
const [isDraggingHeader, setIsDraggingHeader] = useState(false);
|
|
71
|
+
|
|
72
|
+
const computeRelativeBox = useCallback(
|
|
73
|
+
(domEl: HTMLElement): BoxOverlay | null => {
|
|
74
|
+
const container = viewportRef.current;
|
|
75
|
+
if (!container) return null;
|
|
76
|
+
const containerRect = container.getBoundingClientRect();
|
|
77
|
+
const elRect = domEl.getBoundingClientRect();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
top: (elRect.top - containerRect.top) / zoom,
|
|
81
|
+
left: (elRect.left - containerRect.left) / zoom,
|
|
82
|
+
width: elRect.width / zoom,
|
|
83
|
+
height: elRect.height / zoom,
|
|
84
|
+
tagName: domEl.dataset.agyTag || 'View',
|
|
85
|
+
astPath: domEl.dataset.agyAstPath || '0',
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
[zoom]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Keep selected element bounding box updated when screen re-renders or styles change
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const container = viewportRef.current;
|
|
94
|
+
if (
|
|
95
|
+
!container ||
|
|
96
|
+
!selectedElement ||
|
|
97
|
+
selectedElement.screenId !== screen.id
|
|
98
|
+
) {
|
|
99
|
+
setSelectedBox(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const updateSelectionOverlay = () => {
|
|
104
|
+
const match = container.querySelector<HTMLElement>(
|
|
105
|
+
`[data-agy-ast-path="${selectedElement.astPath}"]`
|
|
106
|
+
);
|
|
107
|
+
if (match) {
|
|
108
|
+
setSelectedBox(computeRelativeBox(match));
|
|
109
|
+
} else {
|
|
110
|
+
setSelectedBox(null);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
updateSelectionOverlay();
|
|
115
|
+
const timer = window.setTimeout(updateSelectionOverlay, 90);
|
|
116
|
+
return () => window.clearTimeout(timer);
|
|
117
|
+
}, [selectedElement, screen, computeRelativeBox]);
|
|
118
|
+
|
|
119
|
+
// Artboard header drag handler to reposition the screen on the infinite canvas
|
|
120
|
+
const handleHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
121
|
+
if (e.button !== 0) return;
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
onSelectScreen(screen.id);
|
|
124
|
+
setIsDraggingHeader(true);
|
|
125
|
+
|
|
126
|
+
const startClientX = e.clientX;
|
|
127
|
+
const startClientY = e.clientY;
|
|
128
|
+
const initialX = position.x;
|
|
129
|
+
const initialY = position.y;
|
|
130
|
+
|
|
131
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
132
|
+
const dx = (moveEvent.clientX - startClientX) / zoom;
|
|
133
|
+
const dy = (moveEvent.clientY - startClientY) / zoom;
|
|
134
|
+
onMoveArtboard(
|
|
135
|
+
screen.id,
|
|
136
|
+
Math.round(initialX + dx),
|
|
137
|
+
Math.round(initialY + dy)
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handlePointerUp = () => {
|
|
142
|
+
setIsDraggingHeader(false);
|
|
143
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
144
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
148
|
+
window.addEventListener('pointerup', handlePointerUp);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Inspect Mode pointer handlers
|
|
152
|
+
const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
153
|
+
if (mode !== 'inspect') return;
|
|
154
|
+
const targetEl = findNearestAgyElement(e.target, viewportRef.current);
|
|
155
|
+
if (!targetEl) {
|
|
156
|
+
setHoverBox(null);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
setHoverBox(computeRelativeBox(targetEl));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handlePointerLeave = () => {
|
|
163
|
+
setHoverBox(null);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
167
|
+
if (mode !== 'inspect') return;
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
|
|
171
|
+
onSelectScreen(screen.id);
|
|
172
|
+
const targetEl = findNearestAgyElement(e.target, viewportRef.current);
|
|
173
|
+
if (!targetEl) return;
|
|
174
|
+
|
|
175
|
+
const file = targetEl.dataset.agyFile || screen.filePath;
|
|
176
|
+
const astPath = targetEl.dataset.agyAstPath || '0';
|
|
177
|
+
const line = Number.parseInt(targetEl.dataset.agyLine || '1', 10);
|
|
178
|
+
const col = Number.parseInt(targetEl.dataset.agyCol || '0', 10);
|
|
179
|
+
const tagName = targetEl.dataset.agyTag || 'View';
|
|
180
|
+
|
|
181
|
+
setSelectedBox(computeRelativeBox(targetEl));
|
|
182
|
+
onSelectElement({
|
|
183
|
+
screenId: screen.id,
|
|
184
|
+
file,
|
|
185
|
+
astPath,
|
|
186
|
+
line,
|
|
187
|
+
col,
|
|
188
|
+
tagName,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const borderColor = isHighlightedScreen
|
|
193
|
+
? '#10B981'
|
|
194
|
+
: isActiveScreen
|
|
195
|
+
? '#38BDF8'
|
|
196
|
+
: 'rgba(255, 255, 255, 0.09)';
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
style={{
|
|
201
|
+
position: 'absolute',
|
|
202
|
+
left: position.x,
|
|
203
|
+
top: position.y,
|
|
204
|
+
width: position.width,
|
|
205
|
+
userSelect: 'none',
|
|
206
|
+
zIndex: isActiveScreen || isHighlightedScreen ? 20 : 10,
|
|
207
|
+
}}
|
|
208
|
+
onPointerDown={(e) => {
|
|
209
|
+
// Prevent background canvas pan when interacting inside an artboard
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
onSelectScreen(screen.id);
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{/* Minimalist Architectural Artboard Title Header */}
|
|
215
|
+
<div
|
|
216
|
+
onPointerDown={handleHeaderPointerDown}
|
|
217
|
+
style={{
|
|
218
|
+
height: 30,
|
|
219
|
+
marginBottom: 8,
|
|
220
|
+
display: 'flex',
|
|
221
|
+
alignItems: 'center',
|
|
222
|
+
justifyContent: 'space-between',
|
|
223
|
+
padding: '0 4px',
|
|
224
|
+
cursor: isDraggingHeader ? 'grabbing' : 'grab',
|
|
225
|
+
color: isActiveScreen ? '#38BDF8' : '#94A3B8',
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
|
229
|
+
<GripHorizontal size={14} style={{ opacity: 0.7 }} />
|
|
230
|
+
<span
|
|
231
|
+
style={{
|
|
232
|
+
fontSize: 13,
|
|
233
|
+
fontWeight: 600,
|
|
234
|
+
letterSpacing: '-0.015em',
|
|
235
|
+
color: isActiveScreen ? '#F8FAFC' : '#CBD5E1',
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
{screen.name}
|
|
239
|
+
</span>
|
|
240
|
+
<span
|
|
241
|
+
style={{
|
|
242
|
+
fontSize: 11,
|
|
243
|
+
fontFamily: 'var(--font-mono)',
|
|
244
|
+
color: '#64748B',
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
/{screen.routeName}
|
|
248
|
+
</span>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
252
|
+
<span
|
|
253
|
+
className="tabular-nums"
|
|
254
|
+
style={{
|
|
255
|
+
fontSize: 10.5,
|
|
256
|
+
fontFamily: 'var(--font-mono)',
|
|
257
|
+
color: '#64748B',
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
{position.width} × {position.height}
|
|
261
|
+
</span>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
className="zelune-interactive"
|
|
265
|
+
onClick={(e) => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
onFocusScreen(screen.id);
|
|
268
|
+
}}
|
|
269
|
+
title="Center screen in viewport"
|
|
270
|
+
style={{
|
|
271
|
+
display: 'inline-flex',
|
|
272
|
+
alignItems: 'center',
|
|
273
|
+
justifyContent: 'center',
|
|
274
|
+
width: 22,
|
|
275
|
+
height: 22,
|
|
276
|
+
borderRadius: 5,
|
|
277
|
+
border: '1px solid var(--studio-border-subtle)',
|
|
278
|
+
backgroundColor: 'var(--studio-surface-2)',
|
|
279
|
+
color: '#94A3B8',
|
|
280
|
+
cursor: 'pointer',
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<Maximize2 size={11} />
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{/* Pure Rectangular Screen Artboard (No Device Bezel/Frame, Soft Diffusion Shadows) */}
|
|
289
|
+
<div
|
|
290
|
+
ref={viewportRef}
|
|
291
|
+
onPointerMoveCapture={handlePointerMoveCapture}
|
|
292
|
+
onPointerLeave={handlePointerLeave}
|
|
293
|
+
onClickCapture={handleClickCapture}
|
|
294
|
+
style={{
|
|
295
|
+
position: 'relative',
|
|
296
|
+
width: position.width,
|
|
297
|
+
height: position.height,
|
|
298
|
+
backgroundColor: '#0A0B0E',
|
|
299
|
+
border: `1px solid ${borderColor}`,
|
|
300
|
+
borderRadius: 6,
|
|
301
|
+
overflow: 'hidden',
|
|
302
|
+
boxShadow: isHighlightedScreen
|
|
303
|
+
? '0 0 0 3px rgba(16, 185, 129, 0.28), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
|
|
304
|
+
: isActiveScreen
|
|
305
|
+
? '0 0 0 2px rgba(56, 189, 248, 0.22), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
|
|
306
|
+
: '0 22px 44px -15px rgba(0, 0, 0, 0.68)',
|
|
307
|
+
transition: 'border-color var(--duration-micro) var(--ease-out), box-shadow var(--duration-micro) var(--ease-out)',
|
|
308
|
+
display: 'flex',
|
|
309
|
+
flexDirection: 'column',
|
|
310
|
+
cursor: mode === 'inspect' ? 'crosshair' : 'default',
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{screen.parseError ? (
|
|
314
|
+
<div
|
|
315
|
+
style={{
|
|
316
|
+
flex: 1,
|
|
317
|
+
padding: 24,
|
|
318
|
+
backgroundColor: '#18181B',
|
|
319
|
+
color: '#FCA5A5',
|
|
320
|
+
display: 'flex',
|
|
321
|
+
flexDirection: 'column',
|
|
322
|
+
justifyContent: 'center',
|
|
323
|
+
gap: 10,
|
|
324
|
+
fontFamily: 'var(--font-mono)',
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#F87171', fontWeight: 700, fontSize: 13 }}>
|
|
328
|
+
<AlertTriangle size={16} />
|
|
329
|
+
<span>AST Syntax Error in {screen.filePath}</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div style={{ fontSize: 11, color: '#FECACA', whiteSpace: 'pre-wrap' }}>
|
|
332
|
+
{screen.parseError}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
) : ScreenComponent ? (
|
|
336
|
+
<ScreenErrorBoundary screenName={screen.name}>
|
|
337
|
+
<MockNavigationProvider
|
|
338
|
+
routeName={screen.routeName}
|
|
339
|
+
onNavigate={onNavigateFlow}
|
|
340
|
+
>
|
|
341
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
|
|
342
|
+
<ScreenComponent />
|
|
343
|
+
</div>
|
|
344
|
+
</MockNavigationProvider>
|
|
345
|
+
</ScreenErrorBoundary>
|
|
346
|
+
) : (
|
|
347
|
+
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: 13 }}>
|
|
348
|
+
Mounting {screen.name}...
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
{/* Figma-style Hover Bounding Box in Inspect Mode */}
|
|
353
|
+
{mode === 'inspect' &&
|
|
354
|
+
hoverBox &&
|
|
355
|
+
(!selectedBox || hoverBox.astPath !== selectedBox.astPath) && (
|
|
356
|
+
<div
|
|
357
|
+
className="zelune-box-overlay"
|
|
358
|
+
style={{
|
|
359
|
+
position: 'absolute',
|
|
360
|
+
top: hoverBox.top,
|
|
361
|
+
left: hoverBox.left,
|
|
362
|
+
width: hoverBox.width,
|
|
363
|
+
height: hoverBox.height,
|
|
364
|
+
border: '1px dashed #38BDF8',
|
|
365
|
+
backgroundColor: 'rgba(56, 189, 248, 0.06)',
|
|
366
|
+
pointerEvents: 'none',
|
|
367
|
+
zIndex: 40,
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
<span
|
|
371
|
+
className="tabular-nums"
|
|
372
|
+
style={{
|
|
373
|
+
position: 'absolute',
|
|
374
|
+
top: -19,
|
|
375
|
+
left: -1,
|
|
376
|
+
backgroundColor: '#0284C7',
|
|
377
|
+
color: '#FFFFFF',
|
|
378
|
+
fontSize: 10,
|
|
379
|
+
fontWeight: 600,
|
|
380
|
+
fontFamily: 'var(--font-mono)',
|
|
381
|
+
padding: '1px 5px',
|
|
382
|
+
borderRadius: 3,
|
|
383
|
+
whiteSpace: 'nowrap',
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<{hoverBox.tagName}>
|
|
387
|
+
</span>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Figma-style Active Selection Bounding Box */}
|
|
392
|
+
{selectedBox && selectedElement?.screenId === screen.id && (
|
|
393
|
+
<div
|
|
394
|
+
className="zelune-box-overlay"
|
|
395
|
+
style={{
|
|
396
|
+
position: 'absolute',
|
|
397
|
+
top: selectedBox.top,
|
|
398
|
+
left: selectedBox.left,
|
|
399
|
+
width: selectedBox.width,
|
|
400
|
+
height: selectedBox.height,
|
|
401
|
+
border: '1.5px solid #6366F1',
|
|
402
|
+
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
|
403
|
+
pointerEvents: 'none',
|
|
404
|
+
zIndex: 45,
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
{/* Corner resize handles aesthetic */}
|
|
408
|
+
{[
|
|
409
|
+
{ top: -3.5, left: -3.5 },
|
|
410
|
+
{ top: -3.5, right: -3.5 },
|
|
411
|
+
{ bottom: -3.5, left: -3.5 },
|
|
412
|
+
{ bottom: -3.5, right: -3.5 },
|
|
413
|
+
].map((pos, idx) => (
|
|
414
|
+
<span
|
|
415
|
+
key={idx}
|
|
416
|
+
style={{
|
|
417
|
+
position: 'absolute',
|
|
418
|
+
...pos,
|
|
419
|
+
width: 6,
|
|
420
|
+
height: 6,
|
|
421
|
+
backgroundColor: '#FFFFFF',
|
|
422
|
+
border: '1.5px solid #4F46E5',
|
|
423
|
+
borderRadius: 1,
|
|
424
|
+
}}
|
|
425
|
+
/>
|
|
426
|
+
))}
|
|
427
|
+
<span
|
|
428
|
+
className="tabular-nums"
|
|
429
|
+
style={{
|
|
430
|
+
position: 'absolute',
|
|
431
|
+
top: -21,
|
|
432
|
+
left: -1,
|
|
433
|
+
backgroundColor: '#4F46E5',
|
|
434
|
+
color: '#FFFFFF',
|
|
435
|
+
fontSize: 10,
|
|
436
|
+
fontWeight: 700,
|
|
437
|
+
fontFamily: 'var(--font-mono)',
|
|
438
|
+
padding: '2px 6px',
|
|
439
|
+
borderRadius: 3,
|
|
440
|
+
whiteSpace: 'nowrap',
|
|
441
|
+
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
<{selectedBox.tagName}> · L{selectedElement.line}
|
|
445
|
+
</span>
|
|
446
|
+
</div>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InspectedElement, NavigationEdge, ScreenMetadata } from '../server/astEngine';
|
|
2
|
+
|
|
3
|
+
export type CanvasMode = 'inspect' | 'interact';
|
|
4
|
+
|
|
5
|
+
export interface ArtboardPosition {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ArtboardLayoutMap = Record<string, ArtboardPosition>;
|
|
13
|
+
|
|
14
|
+
export interface SelectedElementRef {
|
|
15
|
+
screenId: string;
|
|
16
|
+
file: string;
|
|
17
|
+
astPath: string;
|
|
18
|
+
line: number;
|
|
19
|
+
col: number;
|
|
20
|
+
tagName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type { InspectedElement, NavigationEdge, ScreenMetadata };
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import { CanvasApp } from './canvas/CanvasApp';
|
|
4
|
+
import './styles.css';
|
|
5
|
+
|
|
6
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
7
|
+
<React.StrictMode>
|
|
8
|
+
<CanvasApp />
|
|
9
|
+
</React.StrictMode>
|
|
10
|
+
);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React, { createContext, useContext, useMemo, useEffect } from 'react';
|
|
2
|
+
import { TouchableOpacity, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface NavigationController {
|
|
5
|
+
navigate: (targetRoute: string, params?: Record<string, unknown>) => void;
|
|
6
|
+
goBack: () => void;
|
|
7
|
+
push: (targetRoute: string, params?: Record<string, unknown>) => void;
|
|
8
|
+
replace: (targetRoute: string, params?: Record<string, unknown>) => void;
|
|
9
|
+
back: () => void;
|
|
10
|
+
setOptions: (options?: Record<string, unknown>) => void;
|
|
11
|
+
canGoBack: () => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MockNavigationContextValue {
|
|
15
|
+
currentRoute: string;
|
|
16
|
+
params: Record<string, unknown>;
|
|
17
|
+
navigation: NavigationController;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeNavTarget(raw: string): string {
|
|
21
|
+
const cleaned = String(raw ?? '')
|
|
22
|
+
.replace(/^\/+/, '')
|
|
23
|
+
.replace(/\([^)]+\)\/?/g, '')
|
|
24
|
+
.replace(/Screen$/, '')
|
|
25
|
+
.replace(/\/index$/, '');
|
|
26
|
+
if (!cleaned || cleaned === 'index') return 'Home';
|
|
27
|
+
if (cleaned.includes('-') || cleaned.includes('/') || cleaned === cleaned.toLowerCase()) {
|
|
28
|
+
return cleaned
|
|
29
|
+
.split(/[-_/]/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
32
|
+
.join('');
|
|
33
|
+
}
|
|
34
|
+
return cleaned;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MockNavigationContext = createContext<MockNavigationContextValue>({
|
|
38
|
+
currentRoute: 'Home',
|
|
39
|
+
params: {},
|
|
40
|
+
navigation: {
|
|
41
|
+
navigate: () => undefined,
|
|
42
|
+
goBack: () => undefined,
|
|
43
|
+
push: () => undefined,
|
|
44
|
+
replace: () => undefined,
|
|
45
|
+
back: () => undefined,
|
|
46
|
+
setOptions: () => undefined,
|
|
47
|
+
canGoBack: () => true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export interface MockNavigationProviderProps {
|
|
52
|
+
routeName: string;
|
|
53
|
+
params?: Record<string, unknown>;
|
|
54
|
+
onNavigate?: (fromScreen: string, toScreen: string, params?: Record<string, unknown>) => void;
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const MockNavigationProvider: React.FC<MockNavigationProviderProps> = ({
|
|
59
|
+
routeName,
|
|
60
|
+
params = {},
|
|
61
|
+
onNavigate,
|
|
62
|
+
children,
|
|
63
|
+
}) => {
|
|
64
|
+
const value = useMemo<MockNavigationContextValue>(() => {
|
|
65
|
+
const trigger = (targetRoute: string, navParams?: Record<string, unknown>) => {
|
|
66
|
+
onNavigate?.(routeName, normalizeNavTarget(targetRoute), navParams);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
currentRoute: routeName,
|
|
70
|
+
params,
|
|
71
|
+
navigation: {
|
|
72
|
+
navigate: trigger,
|
|
73
|
+
push: trigger,
|
|
74
|
+
replace: trigger,
|
|
75
|
+
goBack: () => onNavigate?.(routeName, 'Home'),
|
|
76
|
+
back: () => onNavigate?.(routeName, 'Home'),
|
|
77
|
+
setOptions: () => undefined,
|
|
78
|
+
canGoBack: () => true,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}, [routeName, params, onNavigate]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<MockNavigationContext.Provider value={value}>
|
|
85
|
+
{children}
|
|
86
|
+
</MockNavigationContext.Provider>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// React Navigation Shims (@react-navigation/native)
|
|
91
|
+
export function useNavigation(): NavigationController {
|
|
92
|
+
return useContext(MockNavigationContext).navigation;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useRoute(): { key: string; name: string; params: Record<string, unknown> } {
|
|
96
|
+
const ctx = useContext(MockNavigationContext);
|
|
97
|
+
return {
|
|
98
|
+
key: ctx.currentRoute,
|
|
99
|
+
name: ctx.currentRoute,
|
|
100
|
+
params: ctx.params,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useFocusEffect(effect: () => void | (() => void)): void {
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
return effect();
|
|
107
|
+
}, [effect]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function useIsFocused(): boolean {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const NavigationContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
|
115
|
+
<>{children}</>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Expo Router Shims (expo-router)
|
|
119
|
+
export function useRouter(): NavigationController {
|
|
120
|
+
return useContext(MockNavigationContext).navigation;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const router: NavigationController = {
|
|
124
|
+
navigate: () => undefined,
|
|
125
|
+
push: () => undefined,
|
|
126
|
+
replace: () => undefined,
|
|
127
|
+
goBack: () => undefined,
|
|
128
|
+
back: () => undefined,
|
|
129
|
+
setOptions: () => undefined,
|
|
130
|
+
canGoBack: () => true,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function useLocalSearchParams<T extends Record<string, unknown> = Record<string, string>>(): T {
|
|
134
|
+
const ctx = useContext(MockNavigationContext);
|
|
135
|
+
return ctx.params as T;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function useGlobalSearchParams<T extends Record<string, unknown> = Record<string, string>>(): T {
|
|
139
|
+
const ctx = useContext(MockNavigationContext);
|
|
140
|
+
return ctx.params as T;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function usePathname(): string {
|
|
144
|
+
const ctx = useContext(MockNavigationContext);
|
|
145
|
+
return `/${ctx.currentRoute.toLowerCase()}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useSegments(): string[] {
|
|
149
|
+
const ctx = useContext(MockNavigationContext);
|
|
150
|
+
return [ctx.currentRoute.toLowerCase()];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const Link: React.FC<{
|
|
154
|
+
href: string | { pathname: string; params?: Record<string, unknown> };
|
|
155
|
+
asChild?: boolean;
|
|
156
|
+
children?: React.ReactNode;
|
|
157
|
+
style?: unknown;
|
|
158
|
+
}> = ({ href, children, style }) => {
|
|
159
|
+
const nav = useContext(MockNavigationContext).navigation;
|
|
160
|
+
const target = typeof href === 'string' ? href : href.pathname;
|
|
161
|
+
return (
|
|
162
|
+
<TouchableOpacity
|
|
163
|
+
activeOpacity={0.78}
|
|
164
|
+
style={style as never}
|
|
165
|
+
onPress={() => nav.push(target)}
|
|
166
|
+
>
|
|
167
|
+
{children}
|
|
168
|
+
</TouchableOpacity>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const NoopLayoutScreen: React.FC<{ options?: unknown }> & {
|
|
173
|
+
Screen: React.FC<{ name?: string; options?: unknown }>;
|
|
174
|
+
} = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
|
|
175
|
+
NoopLayoutScreen.Screen = () => null;
|
|
176
|
+
|
|
177
|
+
export const Stack = NoopLayoutScreen;
|
|
178
|
+
export const Tabs = NoopLayoutScreen;
|
|
179
|
+
export const Slot: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
|
180
|
+
|
|
181
|
+
// React Native Safe Area Context Shims (react-native-safe-area-context)
|
|
182
|
+
export const SafeAreaProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
|
183
|
+
<View style={{ flex: 1 }}>{children}</View>
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
export const SafeAreaView: React.FC<{
|
|
187
|
+
children?: React.ReactNode;
|
|
188
|
+
style?: unknown;
|
|
189
|
+
edges?: string[];
|
|
190
|
+
}> = ({ children, style }) => (
|
|
191
|
+
<View style={[{ flex: 1 }, style as never]}>{children}</View>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
export function useSafeAreaInsets(): { top: number; right: number; bottom: number; left: number } {
|
|
195
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Expo Status Bar Shim (expo-status-bar)
|
|
199
|
+
export const StatusBar: React.FC<{ style?: string; hidden?: boolean; backgroundColor?: string }> = () => null;
|