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,1265 @@
1
+ import { v4 as uuid } from 'uuid'
2
+ import { create } from 'zustand'
3
+ import { primitiveRegistry } from '../data/primitiveRegistry'
4
+ import type {
5
+ AppShell,
6
+ AppState,
7
+ ComponentType,
8
+ Page,
9
+ PrimitiveType,
10
+ VisualNode,
11
+ } from '../types'
12
+ import { createPageSourcePath } from '../utils/codegen'
13
+ import {
14
+ createProjectSchema,
15
+ loadProjectSchema,
16
+ saveProjectSchema,
17
+ syncTailwindClasses,
18
+ } from '../utils/projectPersistence'
19
+
20
+ const generateId = () => uuid()
21
+ const projectCacheKey = 'visualbuild:project-cache'
22
+
23
+ const defaultAppShell: AppShell = {
24
+ header: null,
25
+ footer: null,
26
+ }
27
+ const defaultProjectName = 'my-blog'
28
+
29
+ const defaultPage: Page = {
30
+ id: 'page-home',
31
+ name: 'Home',
32
+ route: '/',
33
+ sourcePath: createPageSourcePath('Home'),
34
+ root: createPageRoot('page-home'),
35
+ components: [
36
+ createComponentPreset('Navbar', {
37
+ navId: 'shell-navbar',
38
+ logo: 'My Blog',
39
+ }),
40
+ createComponentPreset('Hero', {
41
+ sectionId: 'home-hero',
42
+ title: 'Welcome to My Blog',
43
+ subtitle: 'Built visually. Owned as React code.',
44
+ cta: 'Start Building',
45
+ }),
46
+ ],
47
+ }
48
+
49
+ const cachedProject = readCachedProject()
50
+ let tailwindSyncTimeout: number | null = null
51
+ let activeProjectId = ''
52
+
53
+ export const useAppStore = create<AppState>((set, get) => ({
54
+ projectName: cachedProject?.projectName ?? defaultProjectName,
55
+ appShell: cachedProject?.appShell ?? defaultAppShell,
56
+ pages: cachedProject?.pages ?? [],
57
+ activePageId: cachedProject?.activePageId ?? '',
58
+ selectedComponentId: null,
59
+ viewport: 'desktop',
60
+ previewMode: false,
61
+ isProjectLoaded: false,
62
+
63
+ loadProject: async () => {
64
+ const schema = await loadProjectSchema().catch((error: unknown) => {
65
+ console.warn('Failed to load visualbuild/pages.json', error)
66
+ return null
67
+ })
68
+
69
+ if (!schema || schema.pages.length === 0) {
70
+ if (cachedProject) {
71
+ activeProjectId = cachedProject.projectId
72
+ scheduleTailwindSync(cachedProject.pages, cachedProject.appShell)
73
+ set({
74
+ projectName: cachedProject.projectName,
75
+ appShell: cachedProject.appShell,
76
+ pages: cachedProject.pages,
77
+ activePageId: cachedProject.activePageId,
78
+ selectedComponentId: null,
79
+ isProjectLoaded: true,
80
+ })
81
+ return
82
+ }
83
+
84
+ activeProjectId = defaultProjectName
85
+ if (get().isProjectLoaded) {
86
+ return
87
+ }
88
+
89
+ cacheProject([defaultPage], defaultAppShell, false, defaultProjectName)
90
+
91
+ set({
92
+ projectName: defaultProjectName,
93
+ appShell: defaultAppShell,
94
+ pages: [defaultPage],
95
+ activePageId: defaultPage.id,
96
+ selectedComponentId: null,
97
+ isProjectLoaded: true,
98
+ })
99
+ return
100
+ }
101
+
102
+ const projectName = schema.meta?.projectName ?? defaultProjectName
103
+ activeProjectId = schema.runtimeProjectId ?? projectName
104
+
105
+ if (
106
+ cachedProject?.dirty &&
107
+ cachedProject.projectId === activeProjectId
108
+ ) {
109
+ scheduleTailwindSync(cachedProject.pages, cachedProject.appShell)
110
+ set({
111
+ projectName: cachedProject.projectName,
112
+ appShell: cachedProject.appShell,
113
+ pages: cachedProject.pages,
114
+ activePageId: cachedProject.activePageId,
115
+ selectedComponentId: null,
116
+ isProjectLoaded: true,
117
+ })
118
+ return
119
+ }
120
+
121
+ const { pages, appShell, changed } = normalizeLoadedProject(
122
+ schema.pages,
123
+ schema.appShell ?? defaultAppShell
124
+ )
125
+ cacheProject(pages, appShell, false, projectName)
126
+
127
+ set({
128
+ projectName,
129
+ appShell,
130
+ pages,
131
+ activePageId: pages[0].id,
132
+ selectedComponentId: null,
133
+ isProjectLoaded: true,
134
+ })
135
+
136
+ if (changed) {
137
+ persistProject(pages, appShell, projectName)
138
+ }
139
+ },
140
+
141
+ refreshProjectFromDisk: async (force = false) => {
142
+ const schema = await loadProjectSchema().catch((error: unknown) => {
143
+ console.warn('Failed to refresh visualbuild/pages.json', error)
144
+ return null
145
+ })
146
+
147
+ if (!schema || schema.pages.length === 0) {
148
+ return 'failed'
149
+ }
150
+
151
+ const projectName = schema.meta?.projectName ?? defaultProjectName
152
+ const nextProjectId = schema.runtimeProjectId ?? projectName
153
+ const currentCache = readCachedProject()
154
+
155
+ if (
156
+ !force &&
157
+ currentCache?.dirty &&
158
+ currentCache.projectId === nextProjectId
159
+ ) {
160
+ return 'skipped-dirty'
161
+ }
162
+
163
+ const { pages, appShell } = normalizeLoadedProject(
164
+ schema.pages,
165
+ schema.appShell ?? defaultAppShell
166
+ )
167
+ const currentActivePageId = get().activePageId
168
+ const activePageId = pages.some(
169
+ (page) => page.id === currentActivePageId
170
+ )
171
+ ? currentActivePageId
172
+ : pages[0].id
173
+
174
+ activeProjectId = nextProjectId
175
+ cacheProject(pages, appShell, false, projectName)
176
+ set({
177
+ projectName,
178
+ appShell,
179
+ pages,
180
+ activePageId,
181
+ selectedComponentId: null,
182
+ isProjectLoaded: true,
183
+ })
184
+
185
+ return 'updated'
186
+ },
187
+
188
+ saveProject: async () => {
189
+ const { pages, appShell, isProjectLoaded, projectName } = get()
190
+
191
+ if (!isProjectLoaded) {
192
+ return
193
+ }
194
+
195
+ cacheProject(pages, appShell, true, projectName)
196
+ await saveProjectSchema(pages, appShell, projectName)
197
+ cacheProject(pages, appShell, false, projectName)
198
+ },
199
+
200
+ addPage: (name, route) => {
201
+ const pageId = generateId()
202
+ const page: Page = {
203
+ id: pageId,
204
+ name,
205
+ route,
206
+ sourcePath: createPageSourcePath(name),
207
+ root: createPageRoot(pageId),
208
+ components: [],
209
+ }
210
+
211
+ set((state) => {
212
+ const pages = [
213
+ ...state.pages,
214
+ page,
215
+ ]
216
+
217
+ persistProject(pages, state.appShell, state.projectName)
218
+
219
+ return { pages }
220
+ })
221
+
222
+ return page
223
+ },
224
+
225
+ registerPage: (page) =>
226
+ set((state) => {
227
+ const pages = [...state.pages, page]
228
+
229
+ persistProject(pages, state.appShell, state.projectName)
230
+
231
+ return {
232
+ pages,
233
+ activePageId: page.id,
234
+ selectedComponentId: null,
235
+ }
236
+ }),
237
+
238
+ deletePage: (id) =>
239
+ set((state) => {
240
+ if (state.pages.length === 1) return state
241
+ const pages = state.pages.filter((page) => page.id !== id)
242
+ const activePageId =
243
+ state.activePageId === id ? pages[0].id : state.activePageId
244
+
245
+ persistProject(pages, state.appShell, state.projectName)
246
+
247
+ return {
248
+ pages,
249
+ activePageId,
250
+ selectedComponentId:
251
+ state.activePageId === id ? null : state.selectedComponentId,
252
+ }
253
+ }),
254
+
255
+ setActivePage: (id) => set({ activePageId: id, selectedComponentId: null }),
256
+
257
+ setSelectedComponent: (id) => set({ selectedComponentId: id }),
258
+
259
+ setViewport: (viewport) => set({ viewport }),
260
+
261
+ togglePreviewMode: () =>
262
+ set((state) => ({ previewMode: !state.previewMode })),
263
+
264
+ addComponent: (pageId, type) => {
265
+ get().addComponentAt(pageId, type, Number.POSITIVE_INFINITY)
266
+ },
267
+
268
+ addComponentAt: (pageId, type, index) =>
269
+ set((state) => {
270
+ const node = createComponentPreset(type)
271
+ const pages = state.pages.map((page) =>
272
+ page.id === pageId ? insertNodeAtRoot(page, node, index) : page
273
+ )
274
+
275
+ persistProject(pages, state.appShell, state.projectName)
276
+
277
+ return { pages, selectedComponentId: node.id }
278
+ }),
279
+
280
+ addPrimitive: (pageId, type) => {
281
+ get().addPrimitiveAt(pageId, type, Number.POSITIVE_INFINITY)
282
+ },
283
+
284
+ addPrimitiveAt: (pageId, type, index) =>
285
+ set((state) => {
286
+ const node = createPrimitive(type)
287
+ const pages = state.pages.map((page) =>
288
+ page.id === pageId ? insertNodeAtRoot(page, node, index) : page
289
+ )
290
+
291
+ persistProject(pages, state.appShell, state.projectName)
292
+
293
+ return { pages, selectedComponentId: node.id }
294
+ }),
295
+
296
+ addPrimitiveToNode: (parentNodeId, type) => {
297
+ get().addPrimitiveToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
298
+ },
299
+
300
+ addPrimitiveToNodeAt: (parentNodeId, type, index) =>
301
+ set((state) => {
302
+ const node = createPrimitive(type)
303
+ const pages = state.pages.map((page) => ({
304
+ ...page,
305
+ components: addChildNodeAt(page.components, parentNodeId, node, index),
306
+ }))
307
+ const appShell = addChildToAppShell(
308
+ state.appShell,
309
+ parentNodeId,
310
+ node,
311
+ index
312
+ )
313
+
314
+ persistProject(pages, appShell, state.projectName)
315
+
316
+ return { appShell, pages, selectedComponentId: node.id }
317
+ }),
318
+
319
+ addComponentToNode: (parentNodeId, type) => {
320
+ get().addComponentToNodeAt(parentNodeId, type, Number.POSITIVE_INFINITY)
321
+ },
322
+
323
+ addComponentToNodeAt: (parentNodeId, type, index) =>
324
+ set((state) => {
325
+ const node = createComponentPreset(type)
326
+ const pages = state.pages.map((page) => ({
327
+ ...page,
328
+ components: addChildNodeAt(page.components, parentNodeId, node, index),
329
+ }))
330
+ const appShell = addChildToAppShell(
331
+ state.appShell,
332
+ parentNodeId,
333
+ node,
334
+ index
335
+ )
336
+
337
+ persistProject(pages, appShell, state.projectName)
338
+
339
+ return { appShell, pages, selectedComponentId: node.id }
340
+ }),
341
+
342
+ removeNode: (nodeId) =>
343
+ set((state) => {
344
+ const appShell = removeShellNodeById(state.appShell, nodeId)
345
+ const pages = state.pages.map((page) => ({
346
+ ...page,
347
+ components: removeNodeById(page.components, nodeId),
348
+ }))
349
+
350
+ persistProject(pages, appShell, state.projectName)
351
+
352
+ return {
353
+ appShell,
354
+ pages,
355
+ selectedComponentId:
356
+ state.selectedComponentId === nodeId ? null : state.selectedComponentId,
357
+ }
358
+ }),
359
+
360
+ moveNode: (pageId, nodeId, targetParentNodeId, targetIndex) =>
361
+ set((state) => {
362
+ const movedProject = moveNodeInProject(
363
+ state.pages,
364
+ state.appShell,
365
+ pageId,
366
+ nodeId,
367
+ targetParentNodeId,
368
+ targetIndex
369
+ )
370
+
371
+ if (!movedProject) {
372
+ return state
373
+ }
374
+
375
+ persistProject(
376
+ movedProject.pages,
377
+ movedProject.appShell,
378
+ state.projectName
379
+ )
380
+
381
+ return {
382
+ pages: movedProject.pages,
383
+ appShell: movedProject.appShell,
384
+ selectedComponentId: nodeId,
385
+ }
386
+ }),
387
+
388
+ moveComponent: (pageId, nodeId, targetIndex) =>
389
+ set((state) => {
390
+ const pages = state.pages.map((page) =>
391
+ page.id === pageId ? moveRootNode(page, nodeId, targetIndex) : page
392
+ )
393
+
394
+ persistProject(pages, state.appShell, state.projectName)
395
+
396
+ return { pages }
397
+ }),
398
+
399
+ removeComponent: (pageId, nodeId) =>
400
+ set((state) => {
401
+ const pages = state.pages.map((page) =>
402
+ page.id === pageId
403
+ ? { ...page, components: removeNodeById(page.components, nodeId) }
404
+ : page
405
+ )
406
+
407
+ persistProject(pages, state.appShell, state.projectName)
408
+
409
+ return {
410
+ pages,
411
+ selectedComponentId:
412
+ state.selectedComponentId === nodeId ? null : state.selectedComponentId,
413
+ }
414
+ }),
415
+
416
+ setAppShellComponent: (_slot, type) => {
417
+ const slot = type === 'Footer' ? 'footer' : 'header'
418
+
419
+ set((state) => {
420
+ const node = createComponentPreset(type)
421
+ const appShell = {
422
+ ...state.appShell,
423
+ [slot]: node,
424
+ }
425
+
426
+ persistProject(state.pages, appShell, state.projectName)
427
+
428
+ return {
429
+ appShell,
430
+ selectedComponentId: node.id,
431
+ }
432
+ })
433
+ },
434
+
435
+ removeAppShellComponent: (slot) =>
436
+ set((state) => {
437
+ const removedNodeId = state.appShell[slot]?.id
438
+ const appShell = {
439
+ ...state.appShell,
440
+ [slot]: null,
441
+ }
442
+
443
+ persistProject(state.pages, appShell, state.projectName)
444
+
445
+ return {
446
+ appShell,
447
+ selectedComponentId:
448
+ state.selectedComponentId === removedNodeId
449
+ ? null
450
+ : state.selectedComponentId,
451
+ }
452
+ }),
453
+
454
+ updateComponentProps: (_pageId, nodeId, props) => {
455
+ get().updateNodeProps(nodeId, props)
456
+ },
457
+
458
+ updateNodeProps: (nodeId, props) =>
459
+ set((state) => {
460
+ const appShell = updateShellNodeProps(state.appShell, nodeId, props)
461
+ const pages = state.pages.map((page) => ({
462
+ ...page,
463
+ components: updateNodeProps(page.components, nodeId, props),
464
+ }))
465
+
466
+ persistProject(pages, appShell, state.projectName)
467
+
468
+ return { appShell, pages }
469
+ }),
470
+
471
+ updatePageRoot: (pageId, updates) =>
472
+ set((state) => {
473
+ const pages = state.pages.map((page) => {
474
+ if (page.id !== pageId) {
475
+ return page
476
+ }
477
+
478
+ const nextType = updates.type ?? page.root.type
479
+ const defaultProps =
480
+ updates.type && updates.type !== page.root.type
481
+ ? primitiveRegistry[nextType].defaultProps
482
+ : {}
483
+
484
+ return {
485
+ ...page,
486
+ root: {
487
+ ...page.root,
488
+ type: nextType,
489
+ props: {
490
+ ...defaultProps,
491
+ ...page.root.props,
492
+ ...(updates.props ?? {}),
493
+ },
494
+ },
495
+ }
496
+ })
497
+
498
+ persistProject(pages, state.appShell, state.projectName)
499
+
500
+ return { pages }
501
+ }),
502
+
503
+ updateAppShellProps: (slot, props) =>
504
+ set((state) => {
505
+ const node = state.appShell[slot]
506
+
507
+ if (!node) {
508
+ return state
509
+ }
510
+
511
+ const appShell = {
512
+ ...state.appShell,
513
+ [slot]: {
514
+ ...node,
515
+ props: {
516
+ ...node.props,
517
+ ...props,
518
+ },
519
+ },
520
+ }
521
+
522
+ persistProject(state.pages, appShell, state.projectName)
523
+
524
+ return { appShell }
525
+ }),
526
+ }))
527
+
528
+ function createPageRoot(pageId: string, type: PrimitiveType = 'div'): VisualNode {
529
+ return {
530
+ id: `${pageId}:root`,
531
+ type,
532
+ label: 'Page root',
533
+ props: {
534
+ ...primitiveRegistry[type].defaultProps,
535
+ className: 'min-h-screen',
536
+ },
537
+ children: [],
538
+ }
539
+ }
540
+
541
+ function createPrimitive(type: PrimitiveType, props = {}): VisualNode {
542
+ return {
543
+ id: generateId(),
544
+ type,
545
+ props: { ...primitiveRegistry[type].defaultProps, ...props },
546
+ children: [],
547
+ }
548
+ }
549
+
550
+ function createComponentPreset(
551
+ type: ComponentType,
552
+ overrides: Record<string, unknown> = {}
553
+ ): VisualNode {
554
+ if (type === 'Navbar') {
555
+ return {
556
+ id: String(overrides.navId ?? generateId()),
557
+ type: 'nav',
558
+ label: 'Navbar',
559
+ props: {
560
+ ...primitiveRegistry.nav.defaultProps,
561
+ className:
562
+ 'flex items-center justify-between px-6 py-3 bg-gray-900 text-white',
563
+ },
564
+ children: [
565
+ createPrimitive('span', {
566
+ text: String(overrides.logo ?? 'My Blog'),
567
+ className: 'font-bold text-sm',
568
+ }),
569
+ createPrimitive('div', {
570
+ className: 'flex items-center gap-4',
571
+ }),
572
+ createPrimitive('div', {
573
+ className: 'flex items-center gap-2',
574
+ }),
575
+ ],
576
+ }
577
+ }
578
+
579
+ if (type === 'Hero') {
580
+ return {
581
+ id: String(overrides.sectionId ?? generateId()),
582
+ type: 'section',
583
+ label: 'Hero',
584
+ props: {
585
+ className:
586
+ 'bg-gradient-to-br from-blue-600 to-indigo-700 text-white px-8 py-12 text-center',
587
+ },
588
+ children: [
589
+ createPrimitive('h1', {
590
+ text: String(overrides.title ?? 'Welcome to My Blog'),
591
+ className: 'text-2xl font-bold mb-2',
592
+ }),
593
+ createPrimitive('p', {
594
+ text: String(
595
+ overrides.subtitle ?? 'Built visually. Owned as React code.'
596
+ ),
597
+ className: 'text-sm text-blue-100 mb-6',
598
+ }),
599
+ createPrimitive('button', {
600
+ text: String(overrides.cta ?? 'Start Building'),
601
+ className:
602
+ 'px-5 py-2 bg-white text-blue-700 rounded-full text-sm font-semibold',
603
+ }),
604
+ ],
605
+ }
606
+ }
607
+
608
+ if (type === 'Card') {
609
+ return {
610
+ id: generateId(),
611
+ type: 'article',
612
+ label: 'Card',
613
+ props: { ...primitiveRegistry.article.defaultProps },
614
+ children: [
615
+ createPrimitive('h3', {
616
+ text: 'Card Title',
617
+ className: 'font-semibold text-gray-900 mb-1',
618
+ }),
619
+ createPrimitive('p', {
620
+ text: 'Card description goes here.',
621
+ className: 'text-sm text-gray-500',
622
+ }),
623
+ ],
624
+ }
625
+ }
626
+
627
+ if (type === 'Button') {
628
+ return createPrimitive('button', {
629
+ text: 'Click me',
630
+ className: 'px-4 py-1.5 rounded text-sm font-medium bg-blue-600 text-white',
631
+ })
632
+ }
633
+
634
+ return {
635
+ id: generateId(),
636
+ type: 'footer',
637
+ label: 'Footer',
638
+ props: { ...primitiveRegistry.footer.defaultProps },
639
+ children: [
640
+ createPrimitive('span', {
641
+ text: 'Copyright 2026 MySite',
642
+ className: '',
643
+ }),
644
+ ],
645
+ }
646
+ }
647
+
648
+ function insertNodeAtRoot(page: Page, node: VisualNode, index: number): Page {
649
+ const components = [...page.components]
650
+ const safeIndex = Math.max(0, Math.min(index, components.length))
651
+
652
+ components.splice(safeIndex, 0, node)
653
+
654
+ return { ...page, components }
655
+ }
656
+
657
+ function addChildNodeAt(
658
+ nodes: VisualNode[],
659
+ parentNodeId: string,
660
+ child: VisualNode,
661
+ index: number
662
+ ): VisualNode[] {
663
+ return nodes.map((node) =>
664
+ node.id === parentNodeId
665
+ ? { ...node, children: insertNodeAt(node.children, child, index) }
666
+ : {
667
+ ...node,
668
+ children: addChildNodeAt(node.children, parentNodeId, child, index),
669
+ }
670
+ )
671
+ }
672
+
673
+ function addChildToAppShell(
674
+ appShell: AppShell,
675
+ parentNodeId: string,
676
+ child: VisualNode,
677
+ index: number
678
+ ): AppShell {
679
+ return {
680
+ header: appShell.header
681
+ ? insertChildIntoTree(appShell.header, parentNodeId, child, index)
682
+ : null,
683
+ footer: appShell.footer
684
+ ? insertChildIntoTree(appShell.footer, parentNodeId, child, index)
685
+ : null,
686
+ }
687
+ }
688
+
689
+ function removeNodeById(nodes: VisualNode[], nodeId: string): VisualNode[] {
690
+ return nodes
691
+ .filter((node) => node.id !== nodeId)
692
+ .map((node) => ({
693
+ ...node,
694
+ children: removeNodeById(node.children, nodeId),
695
+ }))
696
+ }
697
+
698
+ function removeShellNodeById(appShell: AppShell, nodeId: string): AppShell {
699
+ return {
700
+ header:
701
+ appShell.header?.id === nodeId
702
+ ? null
703
+ : appShell.header
704
+ ? {
705
+ ...appShell.header,
706
+ children: removeNodeById(appShell.header.children, nodeId),
707
+ }
708
+ : null,
709
+ footer:
710
+ appShell.footer?.id === nodeId
711
+ ? null
712
+ : appShell.footer
713
+ ? {
714
+ ...appShell.footer,
715
+ children: removeNodeById(appShell.footer.children, nodeId),
716
+ }
717
+ : null,
718
+ }
719
+ }
720
+
721
+ function updateNodeProps(
722
+ nodes: VisualNode[],
723
+ nodeId: string,
724
+ props: Record<string, unknown>
725
+ ): VisualNode[] {
726
+ return nodes.map((node) =>
727
+ node.id === nodeId
728
+ ? { ...node, props: { ...node.props, ...props } }
729
+ : { ...node, children: updateNodeProps(node.children, nodeId, props) }
730
+ )
731
+ }
732
+
733
+ function updateShellNodeProps(
734
+ appShell: AppShell,
735
+ nodeId: string,
736
+ props: Record<string, unknown>
737
+ ): AppShell {
738
+ return {
739
+ header: appShell.header
740
+ ? updateNodeInTree(appShell.header, nodeId, props)
741
+ : null,
742
+ footer: appShell.footer
743
+ ? updateNodeInTree(appShell.footer, nodeId, props)
744
+ : null,
745
+ }
746
+ }
747
+
748
+ function updateNodeInTree(
749
+ node: VisualNode,
750
+ nodeId: string,
751
+ props: Record<string, unknown>
752
+ ): VisualNode {
753
+ if (node.id === nodeId) {
754
+ return { ...node, props: { ...node.props, ...props } }
755
+ }
756
+
757
+ return {
758
+ ...node,
759
+ children: node.children.map((child) =>
760
+ updateNodeInTree(child, nodeId, props)
761
+ ),
762
+ }
763
+ }
764
+
765
+ type NodeScope = `page:${string}` | 'shell:header' | 'shell:footer'
766
+
767
+ interface NodeLocation {
768
+ node: VisualNode
769
+ parentNodeId: string | null
770
+ index: number
771
+ scope: NodeScope
772
+ }
773
+
774
+ function moveNodeInProject(
775
+ pages: Page[],
776
+ appShell: AppShell,
777
+ pageId: string,
778
+ nodeId: string,
779
+ targetParentNodeId: string | null,
780
+ targetIndex: number
781
+ ) {
782
+ const source = findMovableNode(pages, appShell, nodeId)
783
+
784
+ if (!source) {
785
+ return null
786
+ }
787
+
788
+ if (
789
+ targetParentNodeId === nodeId ||
790
+ (targetParentNodeId !== null &&
791
+ containsNode(source.node, targetParentNodeId))
792
+ ) {
793
+ return null
794
+ }
795
+
796
+ const destination = findDestination(
797
+ pages,
798
+ appShell,
799
+ pageId,
800
+ targetParentNodeId
801
+ )
802
+
803
+ if (!destination) {
804
+ return null
805
+ }
806
+
807
+ if (
808
+ destination.parentNode &&
809
+ !primitiveRegistry[destination.parentNode.type].acceptsChildren
810
+ ) {
811
+ return null
812
+ }
813
+
814
+ let insertionIndex = targetIndex
815
+
816
+ if (
817
+ source.scope === destination.scope &&
818
+ source.parentNodeId === targetParentNodeId &&
819
+ source.index < targetIndex
820
+ ) {
821
+ insertionIndex -= 1
822
+ }
823
+
824
+ let nextPages = pages.map((page) => ({
825
+ ...page,
826
+ components: removeNodeById(page.components, nodeId),
827
+ }))
828
+ let nextAppShell: AppShell = {
829
+ header: appShell.header
830
+ ? {
831
+ ...appShell.header,
832
+ children: removeNodeById(appShell.header.children, nodeId),
833
+ }
834
+ : null,
835
+ footer: appShell.footer
836
+ ? {
837
+ ...appShell.footer,
838
+ children: removeNodeById(appShell.footer.children, nodeId),
839
+ }
840
+ : null,
841
+ }
842
+
843
+ if (targetParentNodeId === null) {
844
+ nextPages = nextPages.map((page) =>
845
+ page.id === pageId
846
+ ? insertNodeAtRoot(page, source.node, insertionIndex)
847
+ : page
848
+ )
849
+ } else {
850
+ nextPages = nextPages.map((page) => ({
851
+ ...page,
852
+ components: addChildNodeAt(
853
+ page.components,
854
+ targetParentNodeId,
855
+ source.node,
856
+ insertionIndex
857
+ ),
858
+ }))
859
+ nextAppShell = addChildToAppShell(
860
+ nextAppShell,
861
+ targetParentNodeId,
862
+ source.node,
863
+ insertionIndex
864
+ )
865
+ }
866
+
867
+ return { pages: nextPages, appShell: nextAppShell }
868
+ }
869
+
870
+ function findMovableNode(
871
+ pages: Page[],
872
+ appShell: AppShell,
873
+ nodeId: string
874
+ ): NodeLocation | null {
875
+ for (const page of pages) {
876
+ const location = findNodeLocation(
877
+ page.components,
878
+ nodeId,
879
+ null,
880
+ `page:${page.id}`
881
+ )
882
+
883
+ if (location) {
884
+ return location
885
+ }
886
+ }
887
+
888
+ if (appShell.header) {
889
+ const location = findNodeLocation(
890
+ appShell.header.children,
891
+ nodeId,
892
+ appShell.header.id,
893
+ 'shell:header'
894
+ )
895
+
896
+ if (location) {
897
+ return location
898
+ }
899
+ }
900
+
901
+ if (appShell.footer) {
902
+ return findNodeLocation(
903
+ appShell.footer.children,
904
+ nodeId,
905
+ appShell.footer.id,
906
+ 'shell:footer'
907
+ )
908
+ }
909
+
910
+ return null
911
+ }
912
+
913
+ function findNodeLocation(
914
+ nodes: VisualNode[],
915
+ nodeId: string,
916
+ parentNodeId: string | null,
917
+ scope: NodeScope
918
+ ): NodeLocation | null {
919
+ for (const [index, node] of nodes.entries()) {
920
+ if (node.id === nodeId) {
921
+ return { node, parentNodeId, index, scope }
922
+ }
923
+
924
+ const childLocation = findNodeLocation(
925
+ node.children,
926
+ nodeId,
927
+ node.id,
928
+ scope
929
+ )
930
+
931
+ if (childLocation) {
932
+ return childLocation
933
+ }
934
+ }
935
+
936
+ return null
937
+ }
938
+
939
+ function findDestination(
940
+ pages: Page[],
941
+ appShell: AppShell,
942
+ pageId: string,
943
+ parentNodeId: string | null
944
+ ): { scope: NodeScope; parentNode: VisualNode | null } | null {
945
+ if (parentNodeId === null) {
946
+ return pages.some((page) => page.id === pageId)
947
+ ? { scope: `page:${pageId}`, parentNode: null }
948
+ : null
949
+ }
950
+
951
+ for (const page of pages) {
952
+ const parentNode = findNodeInTreeList(page.components, parentNodeId)
953
+
954
+ if (parentNode) {
955
+ return { scope: `page:${page.id}`, parentNode }
956
+ }
957
+ }
958
+
959
+ if (appShell.header) {
960
+ const parentNode = findNodeInTree(appShell.header, parentNodeId)
961
+
962
+ if (parentNode) {
963
+ return { scope: 'shell:header', parentNode }
964
+ }
965
+ }
966
+
967
+ if (appShell.footer) {
968
+ const parentNode = findNodeInTree(appShell.footer, parentNodeId)
969
+
970
+ if (parentNode) {
971
+ return { scope: 'shell:footer', parentNode }
972
+ }
973
+ }
974
+
975
+ return null
976
+ }
977
+
978
+ function findNodeInTreeList(nodes: VisualNode[], nodeId: string) {
979
+ for (const node of nodes) {
980
+ const match = findNodeInTree(node, nodeId)
981
+
982
+ if (match) {
983
+ return match
984
+ }
985
+ }
986
+
987
+ return null
988
+ }
989
+
990
+ function findNodeInTree(
991
+ node: VisualNode,
992
+ nodeId: string
993
+ ): VisualNode | null {
994
+ if (node.id === nodeId) {
995
+ return node
996
+ }
997
+
998
+ return findNodeInTreeList(node.children, nodeId)
999
+ }
1000
+
1001
+ function containsNode(node: VisualNode, nodeId: string): boolean {
1002
+ return (
1003
+ node.id === nodeId ||
1004
+ node.children.some((child) => containsNode(child, nodeId))
1005
+ )
1006
+ }
1007
+
1008
+ function insertChildIntoTree(
1009
+ node: VisualNode,
1010
+ parentNodeId: string,
1011
+ child: VisualNode,
1012
+ index: number
1013
+ ): VisualNode {
1014
+ if (node.id === parentNodeId) {
1015
+ return { ...node, children: insertNodeAt(node.children, child, index) }
1016
+ }
1017
+
1018
+ return {
1019
+ ...node,
1020
+ children: node.children.map((nestedChild) =>
1021
+ insertChildIntoTree(nestedChild, parentNodeId, child, index)
1022
+ ),
1023
+ }
1024
+ }
1025
+
1026
+ function insertNodeAt(
1027
+ nodes: VisualNode[],
1028
+ node: VisualNode,
1029
+ index: number
1030
+ ) {
1031
+ const nextNodes = [...nodes]
1032
+ const safeIndex = Math.max(0, Math.min(index, nextNodes.length))
1033
+
1034
+ nextNodes.splice(safeIndex, 0, node)
1035
+
1036
+ return nextNodes
1037
+ }
1038
+
1039
+ function moveRootNode(page: Page, nodeId: string, targetIndex: number): Page {
1040
+ const fromIndex = page.components.findIndex((node) => node.id === nodeId)
1041
+
1042
+ if (fromIndex === -1) {
1043
+ return page
1044
+ }
1045
+
1046
+ const components = [...page.components]
1047
+ const [node] = components.splice(fromIndex, 1)
1048
+ const safeIndex = Math.max(0, Math.min(targetIndex, components.length))
1049
+
1050
+ components.splice(safeIndex, 0, node)
1051
+
1052
+ return { ...page, components }
1053
+ }
1054
+
1055
+ function normalizeLoadedProject(pages: Page[], appShell: AppShell) {
1056
+ let changed = false
1057
+ const normalizedAppShell: AppShell = {
1058
+ header: appShell.header ? normalizeUnknownNode(appShell.header) : null,
1059
+ footer: appShell.footer ? normalizeUnknownNode(appShell.footer) : null,
1060
+ }
1061
+
1062
+ const normalizedPages = pages.map((page) => {
1063
+ const rawPage = page as Page & { root?: unknown; sourcePath?: string }
1064
+ const root = rawPage.root
1065
+ ? normalizePageRoot(rawPage.root, page.id)
1066
+ : createPageRoot(page.id)
1067
+ const normalizedComponents: VisualNode[] = []
1068
+
1069
+ for (const rawNode of page.components) {
1070
+ const node = normalizeUnknownNode(rawNode)
1071
+
1072
+ if (node.label === 'Navbar' && !normalizedAppShell.header) {
1073
+ normalizedAppShell.header = node
1074
+ changed = true
1075
+ continue
1076
+ }
1077
+
1078
+ if (node.label === 'Footer' && !normalizedAppShell.footer) {
1079
+ normalizedAppShell.footer = node
1080
+ changed = true
1081
+ continue
1082
+ }
1083
+
1084
+ normalizedComponents.push(node)
1085
+ }
1086
+
1087
+ if (!rawPage.root) {
1088
+ changed = true
1089
+ }
1090
+
1091
+ const sourcePath =
1092
+ rawPage.sourcePath?.replace(/\\/g, '/') ||
1093
+ createPageSourcePath(page.name)
1094
+
1095
+ if (!rawPage.sourcePath || rawPage.sourcePath !== sourcePath) {
1096
+ changed = true
1097
+ }
1098
+
1099
+ return {
1100
+ ...page,
1101
+ sourcePath,
1102
+ root,
1103
+ components: normalizedComponents,
1104
+ }
1105
+ })
1106
+
1107
+ return {
1108
+ pages: normalizedPages,
1109
+ appShell: normalizedAppShell,
1110
+ changed,
1111
+ }
1112
+ }
1113
+
1114
+ function normalizePageRoot(rawRoot: unknown, pageId: string): VisualNode {
1115
+ const root = normalizeUnknownNode(rawRoot)
1116
+
1117
+ if (!primitiveRegistry[root.type].acceptsChildren) {
1118
+ return createPageRoot(pageId)
1119
+ }
1120
+
1121
+ return {
1122
+ ...root,
1123
+ id: root.id || `${pageId}:root`,
1124
+ label: 'Page root',
1125
+ children: [],
1126
+ }
1127
+ }
1128
+
1129
+ function normalizeUnknownNode(rawNode: unknown): VisualNode {
1130
+ const node = rawNode as {
1131
+ id?: string
1132
+ type?: string
1133
+ label?: string
1134
+ props?: Record<string, unknown>
1135
+ children?: unknown[]
1136
+ }
1137
+
1138
+ if (isPrimitiveType(node.type)) {
1139
+ return {
1140
+ id: node.id ?? generateId(),
1141
+ type: node.type,
1142
+ label: node.label,
1143
+ props: node.props ?? {},
1144
+ children: (node.children ?? []).map((child) => normalizeUnknownNode(child)),
1145
+ }
1146
+ }
1147
+
1148
+ if (node.type === 'Navbar') {
1149
+ return createComponentPreset('Navbar', {
1150
+ navId: node.id,
1151
+ logo: node.props?.logo,
1152
+ })
1153
+ }
1154
+
1155
+ if (node.type === 'Hero') {
1156
+ return createComponentPreset('Hero', {
1157
+ sectionId: node.id,
1158
+ title: node.props?.title,
1159
+ subtitle: node.props?.subtitle,
1160
+ cta: node.props?.cta,
1161
+ })
1162
+ }
1163
+
1164
+ if (node.type === 'Card' || node.type === 'Button' || node.type === 'Footer') {
1165
+ return createComponentPreset(node.type)
1166
+ }
1167
+
1168
+ return createPrimitive('div')
1169
+ }
1170
+
1171
+ function isPrimitiveType(type: unknown): type is PrimitiveType {
1172
+ return typeof type === 'string' && type in primitiveRegistry
1173
+ }
1174
+
1175
+ function persistProject(
1176
+ pages: Page[],
1177
+ appShell: AppShell,
1178
+ projectName: string
1179
+ ) {
1180
+ cacheProject(pages, appShell, true, projectName)
1181
+ }
1182
+
1183
+ function cacheProject(
1184
+ pages: Page[],
1185
+ appShell: AppShell,
1186
+ dirty: boolean,
1187
+ projectName: string
1188
+ ) {
1189
+ if (typeof window === 'undefined') {
1190
+ return
1191
+ }
1192
+
1193
+ try {
1194
+ window.localStorage.setItem(
1195
+ projectCacheKey,
1196
+ JSON.stringify({
1197
+ ...createProjectSchema(pages, appShell, projectName),
1198
+ dirty,
1199
+ cachedAt: Date.now(),
1200
+ projectId: activeProjectId,
1201
+ })
1202
+ )
1203
+ scheduleTailwindSync(pages, appShell)
1204
+ } catch (error) {
1205
+ console.warn('Failed to cache visualbuild project state', error)
1206
+ }
1207
+ }
1208
+
1209
+ function scheduleTailwindSync(pages: Page[], appShell: AppShell) {
1210
+ if (tailwindSyncTimeout !== null) {
1211
+ window.clearTimeout(tailwindSyncTimeout)
1212
+ }
1213
+
1214
+ tailwindSyncTimeout = window.setTimeout(() => {
1215
+ tailwindSyncTimeout = null
1216
+ void syncTailwindClasses(pages, appShell).catch((error: unknown) => {
1217
+ console.warn('Failed to update live Tailwind classes', error)
1218
+ })
1219
+ }, 100)
1220
+ }
1221
+
1222
+ function readCachedProject() {
1223
+ if (typeof window === 'undefined') {
1224
+ return null
1225
+ }
1226
+
1227
+ try {
1228
+ const cached = window.localStorage.getItem(projectCacheKey)
1229
+
1230
+ if (!cached) {
1231
+ return null
1232
+ }
1233
+
1234
+ const schema = JSON.parse(cached) as {
1235
+ meta?: {
1236
+ projectName?: string
1237
+ }
1238
+ appShell?: AppShell
1239
+ pages?: Page[]
1240
+ dirty?: boolean
1241
+ projectId?: string
1242
+ }
1243
+
1244
+ if (!schema.pages || schema.pages.length === 0) {
1245
+ return null
1246
+ }
1247
+
1248
+ const { pages, appShell } = normalizeLoadedProject(
1249
+ schema.pages,
1250
+ schema.appShell ?? defaultAppShell
1251
+ )
1252
+
1253
+ return {
1254
+ projectName: schema.meta?.projectName ?? defaultProjectName,
1255
+ appShell,
1256
+ pages,
1257
+ activePageId: pages[0].id,
1258
+ dirty: schema.dirty === true,
1259
+ projectId: schema.projectId ?? '',
1260
+ }
1261
+ } catch (error) {
1262
+ console.warn('Failed to restore cached visualbuild project state', error)
1263
+ return null
1264
+ }
1265
+ }