create-visualbuild-app 0.1.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.
Files changed (44) hide show
  1. package/README.md +123 -0
  2. package/package.json +85 -0
  3. package/scripts/create-builder-app.mjs +501 -0
  4. package/scripts/vb-dev.mjs +32 -0
  5. package/scripts/vb-generate.mjs +224 -0
  6. package/templates/default/app/README.md +21 -0
  7. package/templates/default/app/eslint.config.js +25 -0
  8. package/templates/default/app/index.html +15 -0
  9. package/templates/default/app/src/index.css +23 -0
  10. package/templates/default/app/src/main.tsx +10 -0
  11. package/templates/default/app/tsconfig.app.json +21 -0
  12. package/templates/default/app/tsconfig.json +7 -0
  13. package/templates/default/app/tsconfig.node.json +19 -0
  14. package/templates/default/app/visualbuild/generated-files.json +3 -0
  15. package/templates/default/app/visualbuild/pages.json +12 -0
  16. package/templates/default/app/vite.config.ts +7 -0
  17. package/templates/default/builder/README.md +21 -0
  18. package/templates/default/builder/eslint.config.js +25 -0
  19. package/templates/default/builder/tsconfig.app.json +21 -0
  20. package/templates/default/builder/tsconfig.json +7 -0
  21. package/templates/default/builder/tsconfig.node.json +22 -0
  22. package/visual-app-builder/index.html +15 -0
  23. package/visual-app-builder/server/parseReactPage.ts +571 -0
  24. package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
  25. package/visual-app-builder/shared/generateReactSource.mjs +443 -0
  26. package/visual-app-builder/src/App.tsx +874 -0
  27. package/visual-app-builder/src/components/Canvas.tsx +1059 -0
  28. package/visual-app-builder/src/components/CodePanel.tsx +812 -0
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
  30. package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
  31. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
  32. package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
  33. package/visual-app-builder/src/components/Topbar.tsx +128 -0
  34. package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
  35. package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
  36. package/visual-app-builder/src/index.css +111 -0
  37. package/visual-app-builder/src/main.tsx +10 -0
  38. package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
  39. package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
  40. package/visual-app-builder/src/types/index.ts +261 -0
  41. package/visual-app-builder/src/utils/codegen.ts +66 -0
  42. package/visual-app-builder/src/utils/projectFiles.ts +146 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
  44. package/visual-app-builder/vite.config.ts +1479 -0
@@ -0,0 +1,874 @@
1
+ import {
2
+ DndContext,
3
+ KeyboardSensor,
4
+ PointerSensor,
5
+ pointerWithin,
6
+ rectIntersection,
7
+ useSensor,
8
+ useSensors,
9
+ type CollisionDetection,
10
+ type DragEndEvent,
11
+ } from '@dnd-kit/core'
12
+ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import Canvas from './components/Canvas'
15
+ import CodePanel, { type CodeView } from './components/CodePanel'
16
+ import ComponentSidebar from './components/ComponentSidebar'
17
+ import PageSwitcher from './components/PageSwitcher'
18
+ import PropertiesPanel from './components/PropertiesPanel'
19
+ import Topbar, { type SaveState } from './components/Topbar'
20
+ import { useAppStore } from './stores/useAppStore'
21
+ import type { ComponentType, Page, PrimitiveType } from './types'
22
+ import { getPageSourcePath } from './utils/codegen'
23
+ import { getGeneratorDiagnostic } from './utils/projectPersistence'
24
+
25
+ type DragData =
26
+ | { kind: 'sidebar-component'; type: ComponentType }
27
+ | { kind: 'primitive'; type: PrimitiveType }
28
+ | { kind: 'canvas-node'; nodeId: string }
29
+ | { kind: 'canvas-root' }
30
+ | { kind: 'node-dropzone'; nodeId: string }
31
+ | {
32
+ kind: 'insertion-zone'
33
+ parentNodeId: string | null
34
+ index: number
35
+ }
36
+
37
+ interface ReverseSyncPayload {
38
+ type: 'reverse-sync-success' | 'reverse-sync-error'
39
+ path: string
40
+ pageId?: string
41
+ message: string
42
+ }
43
+
44
+ interface GeneratorDiagnosticPayload {
45
+ type: 'generator-diagnostic'
46
+ severity: 'error'
47
+ phase: 'generate'
48
+ code: string
49
+ title: string
50
+ message: string
51
+ path: string
52
+ suggestion?: string
53
+ }
54
+
55
+ interface FileSystemPayload {
56
+ type: 'filesystem-change'
57
+ path: string
58
+ action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
59
+ }
60
+
61
+ interface ManagedPagesDeletedPayload {
62
+ type: 'managed-pages-deleted'
63
+ path: string
64
+ pageIds: string[]
65
+ message: string
66
+ }
67
+
68
+ interface ManagedPagesMovedPayload {
69
+ type: 'managed-pages-moved'
70
+ path: string
71
+ targetPath: string
72
+ pages: Array<{
73
+ pageId: string
74
+ previousPath: string
75
+ sourcePath: string
76
+ }>
77
+ message: string
78
+ }
79
+
80
+ type VisualBuildPayload =
81
+ | ReverseSyncPayload
82
+ | GeneratorDiagnosticPayload
83
+ | FileSystemPayload
84
+ | ManagedPagesDeletedPayload
85
+ | ManagedPagesMovedPayload
86
+
87
+ type ReverseSyncNoticeState =
88
+ | (ReverseSyncPayload & {
89
+ status: 'success' | 'error' | 'conflict'
90
+ })
91
+ | (GeneratorDiagnosticPayload & {
92
+ status: 'error'
93
+ })
94
+
95
+ export default function App() {
96
+ const [leftPanelOpen, setLeftPanelOpen] = useState(true)
97
+ const [leftPanelTab, setLeftPanelTab] =
98
+ useState<'files' | 'elements'>('elements')
99
+ const [rightPanelOpen, setRightPanelOpen] = useState(true)
100
+ const [rightPanelTab, setRightPanelTab] = useState<'code' | 'properties'>('code')
101
+ const [codeView, setCodeView] = useState<CodeView>('page')
102
+ const [selectedProjectFile, setSelectedProjectFile] = useState<string | null>(
103
+ null
104
+ )
105
+ const [saveState, setSaveState] = useState<SaveState>('idle')
106
+ const [reverseSyncNotice, setReverseSyncNotice] =
107
+ useState<ReverseSyncNoticeState | null>(null)
108
+ const saveResetTimeout = useRef<number | null>(null)
109
+ const {
110
+ pages,
111
+ activePageId,
112
+ loadProject,
113
+ refreshProjectFromDisk,
114
+ isProjectLoaded,
115
+ previewMode,
116
+ togglePreviewMode,
117
+ saveProject,
118
+ addComponentAt,
119
+ addComponentToNode,
120
+ addComponentToNodeAt,
121
+ addPrimitiveAt,
122
+ addPrimitiveToNode,
123
+ addPrimitiveToNodeAt,
124
+ moveNode,
125
+ setActivePage,
126
+ setAppShellComponent,
127
+ } = useAppStore()
128
+ const sensors = useSensors(
129
+ useSensor(PointerSensor, {
130
+ activationConstraint: {
131
+ distance: 6,
132
+ },
133
+ }),
134
+ useSensor(KeyboardSensor, {
135
+ coordinateGetter: sortableKeyboardCoordinates,
136
+ })
137
+ )
138
+ const activePage = pages.find((page) => page.id === activePageId)
139
+
140
+ useEffect(() => {
141
+ void loadProject()
142
+ }, [loadProject])
143
+
144
+ useEffect(() => {
145
+ const eventSource = new EventSource('/__visualbuild/events')
146
+ const handleReverseSync = (event: MessageEvent<string>) => {
147
+ let payload: VisualBuildPayload
148
+
149
+ try {
150
+ payload = JSON.parse(event.data) as VisualBuildPayload
151
+ } catch {
152
+ return
153
+ }
154
+
155
+ if (payload.type === 'filesystem-change') {
156
+ window.dispatchEvent(
157
+ new CustomEvent('visualbuild:filesystem-changed', {
158
+ detail: payload,
159
+ })
160
+ )
161
+ return
162
+ }
163
+
164
+ if (payload.type === 'managed-pages-deleted') {
165
+ setSelectedProjectFile((currentPath) => {
166
+ if (
167
+ currentPath === payload.path ||
168
+ currentPath?.startsWith(`${payload.path}/`)
169
+ ) {
170
+ return null
171
+ }
172
+
173
+ return currentPath
174
+ })
175
+ setCodeView('page')
176
+ setReverseSyncNotice(null)
177
+ void refreshProjectFromDisk(true).then((result) => {
178
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
179
+
180
+ if (result !== 'updated') {
181
+ setReverseSyncNotice({
182
+ type: 'reverse-sync-error',
183
+ path: payload.path,
184
+ message: 'The page was deleted, but the editor could not refresh.',
185
+ status: 'error',
186
+ })
187
+ return
188
+ }
189
+
190
+ const nextActivePage = useAppStore
191
+ .getState()
192
+ .pages.find(
193
+ (page) => page.id === useAppStore.getState().activePageId
194
+ )
195
+
196
+ if (nextActivePage) {
197
+ window.history.replaceState({}, '', nextActivePage.route)
198
+ }
199
+ })
200
+ return
201
+ }
202
+
203
+ if (payload.type === 'managed-pages-moved') {
204
+ setSelectedProjectFile((currentPath) =>
205
+ currentPath
206
+ ? replacePathPrefix(
207
+ currentPath,
208
+ payload.path,
209
+ payload.targetPath
210
+ )
211
+ : null
212
+ )
213
+ setReverseSyncNotice(null)
214
+ void refreshProjectFromDisk(true).then((result) => {
215
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
216
+
217
+ if (result !== 'updated') {
218
+ setReverseSyncNotice({
219
+ type: 'reverse-sync-error',
220
+ path: payload.targetPath,
221
+ message:
222
+ 'The page source moved, but the editor could not refresh.',
223
+ status: 'error',
224
+ })
225
+ }
226
+ })
227
+ return
228
+ }
229
+
230
+ if (payload.type === 'generator-diagnostic') {
231
+ setReverseSyncNotice({ ...payload, status: 'error' })
232
+ return
233
+ }
234
+
235
+ if (payload.type === 'reverse-sync-error') {
236
+ setReverseSyncNotice({ ...payload, status: 'error' })
237
+ return
238
+ }
239
+
240
+ void refreshProjectFromDisk().then((result) => {
241
+ if (result === 'updated') {
242
+ window.dispatchEvent(
243
+ new Event('visualbuild:filesystem-changed')
244
+ )
245
+ setReverseSyncNotice({ ...payload, status: 'success' })
246
+ return
247
+ }
248
+
249
+ setReverseSyncNotice({
250
+ ...payload,
251
+ status: result === 'skipped-dirty' ? 'conflict' : 'error',
252
+ message:
253
+ result === 'skipped-dirty'
254
+ ? 'Source changed while the canvas has unsaved edits.'
255
+ : 'The source changed, but the canvas could not refresh.',
256
+ })
257
+ })
258
+ }
259
+
260
+ eventSource.addEventListener(
261
+ 'visualbuild',
262
+ handleReverseSync as EventListener
263
+ )
264
+
265
+ return () => eventSource.close()
266
+ }, [refreshProjectFromDisk])
267
+
268
+ useEffect(
269
+ () => () => {
270
+ if (saveResetTimeout.current !== null) {
271
+ window.clearTimeout(saveResetTimeout.current)
272
+ }
273
+ },
274
+ []
275
+ )
276
+
277
+ const handleToggleCode = () => {
278
+ if (previewMode) {
279
+ togglePreviewMode()
280
+ setRightPanelTab('code')
281
+ setRightPanelOpen(true)
282
+ return
283
+ }
284
+
285
+ if (rightPanelOpen && rightPanelTab === 'code') {
286
+ setRightPanelOpen(false)
287
+ return
288
+ }
289
+
290
+ setRightPanelTab('code')
291
+ setRightPanelOpen(true)
292
+ }
293
+
294
+ const handleTogglePreview = () => {
295
+ if (!previewMode && activePage) {
296
+ window.history.replaceState({}, '', activePage.route)
297
+ }
298
+
299
+ togglePreviewMode()
300
+ }
301
+
302
+ const handleSelectProjectFile = (path: string | null) => {
303
+ setSelectedProjectFile(path)
304
+
305
+ if (path) {
306
+ const normalizedPath = path.replace(/\\/g, '/')
307
+ const managedPage = pages.find(
308
+ (page) => getPageSourcePath(page) === normalizedPath
309
+ )
310
+
311
+ if (managedPage) {
312
+ setActivePage(managedPage.id)
313
+ setCodeView('page')
314
+ } else {
315
+ setCodeView('file')
316
+ }
317
+ setRightPanelTab('code')
318
+ setRightPanelOpen(true)
319
+ } else if (codeView === 'file') {
320
+ setCodeView('page')
321
+ }
322
+ }
323
+
324
+ const handleSelectCodeWorkspaceFile = (path: string) => {
325
+ const normalizedPath = path.replace(/\\/g, '/')
326
+ const managedPage = pages.find(
327
+ (page) => getPageSourcePath(page) === normalizedPath
328
+ )
329
+
330
+ setSelectedProjectFile(path)
331
+ if (managedPage) {
332
+ setActivePage(managedPage.id)
333
+ }
334
+ setCodeView('file')
335
+ }
336
+
337
+ const handleSelectPage = (page: Page) => {
338
+ setSelectedProjectFile(getPageSourcePath(page))
339
+ setCodeView('page')
340
+ setRightPanelTab('code')
341
+ setRightPanelOpen(true)
342
+ }
343
+
344
+ const handleDeletePage = (page: Page) => {
345
+ if (selectedProjectFile === getPageSourcePath(page)) {
346
+ setSelectedProjectFile(null)
347
+ setCodeView('page')
348
+ }
349
+ }
350
+
351
+ const handleSave = async () => {
352
+ if (saveState === 'saving') {
353
+ return
354
+ }
355
+
356
+ if (saveResetTimeout.current !== null) {
357
+ window.clearTimeout(saveResetTimeout.current)
358
+ }
359
+
360
+ setSaveState('saving')
361
+ const minimumOverlayTime = delay(450)
362
+
363
+ try {
364
+ await saveProject()
365
+ await minimumOverlayTime
366
+ setSaveState('saved')
367
+ saveResetTimeout.current = window.setTimeout(
368
+ () => setSaveState('idle'),
369
+ 1200
370
+ )
371
+ } catch (error) {
372
+ await minimumOverlayTime
373
+ console.error('Failed to save VisualBuild project', error)
374
+ const diagnostic = getGeneratorDiagnostic(error)
375
+
376
+ if (diagnostic) {
377
+ setReverseSyncNotice({
378
+ type: 'generator-diagnostic',
379
+ ...diagnostic,
380
+ status: 'error',
381
+ })
382
+ }
383
+
384
+ setSaveState('error')
385
+ saveResetTimeout.current = window.setTimeout(
386
+ () => setSaveState('idle'),
387
+ 2400
388
+ )
389
+ }
390
+ }
391
+
392
+ const handleUseSourceChanges = async () => {
393
+ const result = await refreshProjectFromDisk(true)
394
+
395
+ if (result === 'updated') {
396
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
397
+ setReverseSyncNotice((notice) =>
398
+ notice && notice.type !== 'generator-diagnostic'
399
+ ? {
400
+ ...notice,
401
+ status: 'success',
402
+ message: 'Loaded the source version into the canvas.',
403
+ }
404
+ : null
405
+ )
406
+ }
407
+ }
408
+
409
+ const handleDragEnd = (event: DragEndEvent) => {
410
+ const activeData = getDragData(event.active.data.current)
411
+ const overData = getDragData(event.over?.data.current)
412
+
413
+ if (!activeData || !overData || !activePage) {
414
+ return
415
+ }
416
+
417
+ if (overData.kind === 'insertion-zone') {
418
+ if (activeData.kind === 'canvas-node') {
419
+ moveNode(
420
+ activePageId,
421
+ activeData.nodeId,
422
+ overData.parentNodeId,
423
+ overData.index
424
+ )
425
+ return
426
+ }
427
+
428
+ if (activeData.kind === 'primitive') {
429
+ if (overData.parentNodeId === null) {
430
+ addPrimitiveAt(activePageId, activeData.type, overData.index)
431
+ } else {
432
+ addPrimitiveToNodeAt(
433
+ overData.parentNodeId,
434
+ activeData.type,
435
+ overData.index
436
+ )
437
+ }
438
+ return
439
+ }
440
+
441
+ if (activeData.kind === 'sidebar-component') {
442
+ if (isGlobalComponent(activeData.type)) {
443
+ setGlobalComponent(activeData.type, setAppShellComponent)
444
+ } else if (overData.parentNodeId === null) {
445
+ addComponentAt(activePageId, activeData.type, overData.index)
446
+ } else {
447
+ addComponentToNodeAt(
448
+ overData.parentNodeId,
449
+ activeData.type,
450
+ overData.index
451
+ )
452
+ }
453
+ }
454
+
455
+ return
456
+ }
457
+
458
+ if (overData.kind === 'node-dropzone') {
459
+ if (activeData.kind === 'canvas-node') {
460
+ moveNode(
461
+ activePageId,
462
+ activeData.nodeId,
463
+ overData.nodeId,
464
+ Number.POSITIVE_INFINITY
465
+ )
466
+ return
467
+ }
468
+
469
+ if (activeData.kind === 'primitive') {
470
+ addPrimitiveToNode(overData.nodeId, activeData.type)
471
+ return
472
+ }
473
+
474
+ if (activeData.kind === 'sidebar-component') {
475
+ if (isGlobalComponent(activeData.type)) {
476
+ setGlobalComponent(activeData.type, setAppShellComponent)
477
+ return
478
+ }
479
+
480
+ addComponentToNode(overData.nodeId, activeData.type)
481
+ }
482
+
483
+ return
484
+ }
485
+
486
+ if (overData.kind !== 'canvas-root') {
487
+ return
488
+ }
489
+
490
+ if (activeData.kind === 'canvas-node') {
491
+ moveNode(
492
+ activePageId,
493
+ activeData.nodeId,
494
+ null,
495
+ Number.POSITIVE_INFINITY
496
+ )
497
+ return
498
+ }
499
+
500
+ if (activeData.kind === 'primitive') {
501
+ addPrimitiveAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
502
+ return
503
+ }
504
+
505
+ if (activeData.kind === 'sidebar-component') {
506
+ if (isGlobalComponent(activeData.type)) {
507
+ setGlobalComponent(activeData.type, setAppShellComponent)
508
+ return
509
+ }
510
+
511
+ addComponentAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
512
+ }
513
+ }
514
+
515
+ return (
516
+ <div className="flex flex-col h-full bg-gray-950 text-gray-100">
517
+ <Topbar
518
+ showCode={rightPanelOpen && rightPanelTab === 'code'}
519
+ previewMode={previewMode}
520
+ saveState={saveState}
521
+ onToggleCode={handleToggleCode}
522
+ onTogglePreview={handleTogglePreview}
523
+ onSave={() => {
524
+ void handleSave()
525
+ }}
526
+ />
527
+ {reverseSyncNotice && (
528
+ <ReverseSyncNotice
529
+ notice={reverseSyncNotice}
530
+ onDismiss={() => setReverseSyncNotice(null)}
531
+ onUseSource={() => {
532
+ void handleUseSourceChanges()
533
+ }}
534
+ />
535
+ )}
536
+ <DndContext
537
+ sensors={sensors}
538
+ collisionDetection={canvasCollisionDetection}
539
+ onDragEnd={handleDragEnd}
540
+ >
541
+ <div className="flex flex-1 overflow-hidden">
542
+ {!isProjectLoaded ? (
543
+ <div className="flex flex-1 items-center justify-center bg-[#eef1f4] text-sm text-gray-500">
544
+ Loading project...
545
+ </div>
546
+ ) : (
547
+ <>
548
+ {!previewMode && (
549
+ <WorkspacePanel
550
+ side="left"
551
+ open={leftPanelOpen}
552
+ activeTab={leftPanelTab}
553
+ tabs={[
554
+ { id: 'files', label: 'Files' },
555
+ { id: 'elements', label: 'Elements' },
556
+ ]}
557
+ onToggle={() => setLeftPanelOpen((value) => !value)}
558
+ onTabChange={(tab) =>
559
+ setLeftPanelTab(tab as 'files' | 'elements')
560
+ }
561
+ >
562
+ <ComponentSidebar
563
+ view={leftPanelTab}
564
+ selectedProjectFile={selectedProjectFile}
565
+ onSelectProjectFile={handleSelectProjectFile}
566
+ />
567
+ </WorkspacePanel>
568
+ )}
569
+ <div className="flex flex-col flex-1 overflow-hidden">
570
+ <div className="flex flex-1 overflow-hidden">
571
+ <Canvas />
572
+ </div>
573
+ {!previewMode && (
574
+ <PageSwitcher
575
+ onSelectPage={handleSelectPage}
576
+ onDeletePage={handleDeletePage}
577
+ />
578
+ )}
579
+ </div>
580
+ {!previewMode && (
581
+ <WorkspacePanel
582
+ side="right"
583
+ open={rightPanelOpen}
584
+ activeTab={rightPanelTab}
585
+ tabs={[
586
+ { id: 'code', label: 'Code' },
587
+ { id: 'properties', label: 'Properties' },
588
+ ]}
589
+ onToggle={() => setRightPanelOpen((value) => !value)}
590
+ onTabChange={(tab) => {
591
+ setRightPanelTab(tab as 'code' | 'properties')
592
+ setRightPanelOpen(true)
593
+ }}
594
+ >
595
+ {rightPanelTab === 'code' ? (
596
+ <CodePanel
597
+ embedded
598
+ view={codeView}
599
+ filePath={selectedProjectFile}
600
+ onViewChange={setCodeView}
601
+ onPageRegistered={handleSelectPage}
602
+ onWorkspaceSelectFile={handleSelectCodeWorkspaceFile}
603
+ />
604
+ ) : (
605
+ <PropertiesPanel embedded />
606
+ )}
607
+ </WorkspacePanel>
608
+ )}
609
+ </>
610
+ )}
611
+ </div>
612
+ </DndContext>
613
+ {saveState === 'saving' && <SaveOverlay />}
614
+ </div>
615
+ )
616
+ }
617
+
618
+ function replacePathPrefix(
619
+ value: string,
620
+ previousPath: string,
621
+ targetPath: string
622
+ ) {
623
+ if (
624
+ value !== previousPath &&
625
+ !value.startsWith(`${previousPath}/`)
626
+ ) {
627
+ return value
628
+ }
629
+
630
+ return `${targetPath}${value.slice(previousPath.length)}`
631
+ }
632
+
633
+ function ReverseSyncNotice({
634
+ notice,
635
+ onDismiss,
636
+ onUseSource,
637
+ }: {
638
+ notice: ReverseSyncNoticeState
639
+ onDismiss: () => void
640
+ onUseSource: () => void
641
+ }) {
642
+ const tone =
643
+ notice.status === 'success'
644
+ ? 'border-emerald-400/30 bg-emerald-950/95'
645
+ : notice.status === 'conflict'
646
+ ? 'border-amber-400/30 bg-amber-950/95'
647
+ : 'border-red-400/30 bg-red-950/95'
648
+
649
+ return (
650
+ <div
651
+ className={`fixed right-4 top-16 z-[90] w-[390px] max-w-[calc(100vw-2rem)] rounded-md border px-4 py-3 text-white shadow-2xl ${tone}`}
652
+ role={notice.status === 'success' ? 'status' : 'alert'}
653
+ aria-live="polite"
654
+ >
655
+ <div className="flex items-start gap-3">
656
+ <div className="min-w-0 flex-1">
657
+ <p className="text-sm font-semibold">
658
+ {notice.type === 'generator-diagnostic'
659
+ ? notice.title
660
+ : notice.status === 'success'
661
+ ? 'Source synced'
662
+ : notice.status === 'conflict'
663
+ ? 'Choose the version to keep'
664
+ : 'Reverse sync paused'}
665
+ </p>
666
+ <p className="mt-1 text-xs leading-5 text-white/75">
667
+ {notice.message}
668
+ </p>
669
+ <p className="mt-1 truncate font-mono text-[10px] text-white/50">
670
+ {notice.path}
671
+ </p>
672
+ {notice.type === 'generator-diagnostic' &&
673
+ notice.suggestion && (
674
+ <p className="mt-2 text-xs leading-5 text-white/70">
675
+ {notice.suggestion}
676
+ </p>
677
+ )}
678
+ {notice.status === 'conflict' && (
679
+ <button
680
+ type="button"
681
+ onClick={onUseSource}
682
+ className="mt-3 cursor-pointer rounded bg-white px-3 py-1.5 text-xs font-semibold text-gray-950 hover:bg-gray-100"
683
+ >
684
+ Load source changes
685
+ </button>
686
+ )}
687
+ </div>
688
+ <button
689
+ type="button"
690
+ onClick={onDismiss}
691
+ aria-label="Dismiss reverse sync notice"
692
+ title="Dismiss"
693
+ className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-base text-white/60 hover:bg-white/10 hover:text-white"
694
+ >
695
+ x
696
+ </button>
697
+ </div>
698
+ </div>
699
+ )
700
+ }
701
+
702
+ function WorkspacePanel({
703
+ side,
704
+ open,
705
+ activeTab,
706
+ tabs,
707
+ onToggle,
708
+ onTabChange,
709
+ children,
710
+ }: {
711
+ side: 'left' | 'right'
712
+ open: boolean
713
+ activeTab: string
714
+ tabs: { id: string; label: string }[]
715
+ onToggle: () => void
716
+ onTabChange: (tab: string) => void
717
+ children: React.ReactNode
718
+ }) {
719
+ const borderClass = side === 'left' ? 'border-r' : 'border-l'
720
+ const collapseLabel = open
721
+ ? `Collapse ${side} panel`
722
+ : `Expand ${side} panel`
723
+ const arrow = side === 'left' ? (open ? '<' : '>') : open ? '>' : '<'
724
+
725
+ if (!open) {
726
+ return (
727
+ <div
728
+ className={`flex w-8 shrink-0 items-start justify-center border-white/[0.06] bg-panel pt-2 ${borderClass}`}
729
+ >
730
+ <button
731
+ type="button"
732
+ onClick={onToggle}
733
+ aria-label={collapseLabel}
734
+ title={collapseLabel}
735
+ className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
736
+ >
737
+ {arrow}
738
+ </button>
739
+ </div>
740
+ )
741
+ }
742
+
743
+ return (
744
+ <aside
745
+ className={`${side === 'left' ? 'w-64' : 'w-[460px]'} ${borderClass} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
746
+ >
747
+ <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-1.5">
748
+ <div className="flex min-w-0 flex-1 items-center gap-1">
749
+ {tabs.map((tab) => (
750
+ <button
751
+ key={tab.id}
752
+ type="button"
753
+ onClick={() => onTabChange(tab.id)}
754
+ className={`h-7 cursor-pointer rounded px-2.5 text-[11px] font-medium transition-colors ${
755
+ activeTab === tab.id
756
+ ? 'bg-white/[0.1] text-white'
757
+ : 'text-gray-500 hover:bg-white/[0.05] hover:text-gray-200'
758
+ }`}
759
+ >
760
+ {tab.label}
761
+ </button>
762
+ ))}
763
+ </div>
764
+ <button
765
+ type="button"
766
+ onClick={onToggle}
767
+ aria-label={collapseLabel}
768
+ title={collapseLabel}
769
+ className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
770
+ >
771
+ {arrow}
772
+ </button>
773
+ </div>
774
+ <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
775
+ </aside>
776
+ )
777
+ }
778
+
779
+ function SaveOverlay() {
780
+ return (
781
+ <div
782
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-gray-950/25 px-4 backdrop-blur-sm"
783
+ role="status"
784
+ aria-live="polite"
785
+ aria-label="Saving project"
786
+ >
787
+ <div className="flex w-[320px] max-w-full items-center gap-3 rounded-lg border border-white/10 bg-[#171a20] px-5 py-4 text-white shadow-2xl">
788
+ <span
789
+ className="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-white/20 border-t-blue-400"
790
+ aria-hidden="true"
791
+ />
792
+ <div className="min-w-0">
793
+ <p className="text-sm font-medium">Saving project...</p>
794
+ <p className="mt-0.5 text-xs text-gray-400">
795
+ Updating your React files
796
+ </p>
797
+ </div>
798
+ </div>
799
+ </div>
800
+ )
801
+ }
802
+
803
+ function delay(milliseconds: number) {
804
+ return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds))
805
+ }
806
+
807
+ function isGlobalComponent(type: ComponentType) {
808
+ return type === 'Navbar' || type === 'Footer'
809
+ }
810
+
811
+ function setGlobalComponent(
812
+ type: ComponentType,
813
+ setAppShellComponent: ReturnType<typeof useAppStore.getState>['setAppShellComponent']
814
+ ) {
815
+ setAppShellComponent(type === 'Footer' ? 'footer' : 'header', type)
816
+ }
817
+
818
+ function getDragData(data: unknown): DragData | null {
819
+ if (!data || typeof data !== 'object' || !('kind' in data)) {
820
+ return null
821
+ }
822
+
823
+ return data as DragData
824
+ }
825
+
826
+ const canvasCollisionDetection: CollisionDetection = (args) => {
827
+ const activeData = getDragData(args.active.data.current)
828
+ const isInvalidSelfTarget = (data: DragData | null) =>
829
+ activeData?.kind === 'canvas-node' &&
830
+ ((data?.kind === 'node-dropzone' &&
831
+ data.nodeId === activeData.nodeId) ||
832
+ (data?.kind === 'insertion-zone' &&
833
+ data.parentNodeId === activeData.nodeId))
834
+ const insertionContainers = args.droppableContainers.filter((container) => {
835
+ const data = getDragData(container.data.current)
836
+ return data?.kind === 'insertion-zone' && !isInvalidSelfTarget(data)
837
+ })
838
+ const insertionCollisions = pointerWithin({
839
+ ...args,
840
+ droppableContainers: insertionContainers,
841
+ })
842
+
843
+ if (insertionCollisions.length > 0) {
844
+ return insertionCollisions
845
+ }
846
+
847
+ const nodeContainers = args.droppableContainers.filter((container) => {
848
+ const data = getDragData(container.data.current)
849
+ return data?.kind === 'node-dropzone' && !isInvalidSelfTarget(data)
850
+ })
851
+ const nodeCollisions = pointerWithin({
852
+ ...args,
853
+ droppableContainers: nodeContainers,
854
+ })
855
+
856
+ if (nodeCollisions.length > 0) {
857
+ return [...nodeCollisions].sort((first, second) => {
858
+ const firstRect = args.droppableRects.get(first.id)
859
+ const secondRect = args.droppableRects.get(second.id)
860
+ const firstArea = firstRect ? firstRect.width * firstRect.height : Infinity
861
+ const secondArea = secondRect
862
+ ? secondRect.width * secondRect.height
863
+ : Infinity
864
+
865
+ return firstArea - secondArea
866
+ })
867
+ }
868
+
869
+ const pointerCollisions = pointerWithin(args)
870
+
871
+ return pointerCollisions.length > 0
872
+ ? pointerCollisions
873
+ : rectIntersection(args)
874
+ }