@siteable/core 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/README.md +82 -0
  4. package/dist/index.d.ts +401 -0
  5. package/dist/index.js +5389 -0
  6. package/package.json +66 -0
  7. package/src/blocks/BlockWrapper.tsx +149 -0
  8. package/src/blocks/banner/BannerBlock.tsx +38 -0
  9. package/src/blocks/contact/ContactBlock.tsx +63 -0
  10. package/src/blocks/content/ContentBlock.tsx +38 -0
  11. package/src/blocks/cta/CtaBlock.tsx +67 -0
  12. package/src/blocks/divider/DividerBlock.tsx +30 -0
  13. package/src/blocks/faq/FaqBlock.tsx +82 -0
  14. package/src/blocks/features/FeaturesBlock.tsx +170 -0
  15. package/src/blocks/footer/FooterBlock.tsx +136 -0
  16. package/src/blocks/gallery/GalleryBlock.tsx +67 -0
  17. package/src/blocks/hero/HeroBlock.tsx +163 -0
  18. package/src/blocks/image/ImageBlock.tsx +78 -0
  19. package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
  20. package/src/blocks/navbar/NavbarBlock.tsx +102 -0
  21. package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
  22. package/src/blocks/pricing/PricingBlock.tsx +173 -0
  23. package/src/blocks/registry.tsx +90 -0
  24. package/src/blocks/stats/StatsBlock.tsx +96 -0
  25. package/src/blocks/team/TeamBlock.tsx +50 -0
  26. package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
  27. package/src/blocks/types.ts +72 -0
  28. package/src/blocks/video/VideoBlock.tsx +58 -0
  29. package/src/editor/AgentPanel.tsx +318 -0
  30. package/src/editor/Canvas.tsx +95 -0
  31. package/src/editor/CanvasEmpty.tsx +40 -0
  32. package/src/editor/CanvasToolbar.tsx +366 -0
  33. package/src/editor/DesignPanel.tsx +224 -0
  34. package/src/editor/EditorLayout.tsx +196 -0
  35. package/src/editor/GenerationOverlay.tsx +201 -0
  36. package/src/editor/JsonDrawer.tsx +139 -0
  37. package/src/editor/LayersPanel.tsx +259 -0
  38. package/src/editor/LeftSidebar.tsx +136 -0
  39. package/src/editor/PropertiesPanel.tsx +564 -0
  40. package/src/editor/RightSidebar.tsx +85 -0
  41. package/src/editor/ShortcutsModal.tsx +126 -0
  42. package/src/editor/VersionHistory.tsx +110 -0
  43. package/src/index.ts +133 -0
  44. package/src/lib/block-metadata.ts +167 -0
  45. package/src/lib/export-html.ts +1424 -0
  46. package/src/lib/generate-site.ts +206 -0
  47. package/src/lib/generation-prompt.ts +64 -0
  48. package/src/lib/markdown.ts +88 -0
  49. package/src/lib/templates.ts +139 -0
  50. package/src/lib/theme-presets.ts +330 -0
  51. package/src/lib/useGoogleFonts.ts +49 -0
  52. package/src/lib/useScrollReveal.ts +28 -0
  53. package/src/settings/GeminiKeyInputField.tsx +82 -0
  54. package/src/store/configStore.ts +344 -0
  55. package/src/store/editorStore.ts +50 -0
  56. package/src/styles.css +210 -0
@@ -0,0 +1,224 @@
1
+ import { useState, useMemo } from 'react'
2
+ import { ChevronDown } from 'lucide-react'
3
+ import { useConfigStore } from '@/store/configStore'
4
+ import type { ThemeConfig } from '@/blocks/types'
5
+ import { themePresets, resolveTheme, googleFontOptions } from '@/lib/theme-presets'
6
+
7
+ function ColorInput({ value, onInput, onChange }: { value: string; onInput: (v: string) => void; onChange: (v: string) => void }) {
8
+ return (
9
+ <div className="flex items-center gap-1.5">
10
+ <input
11
+ type="color"
12
+ value={value}
13
+ onInput={(e) => onInput((e.target as HTMLInputElement).value)}
14
+ onChange={(e) => onChange(e.target.value)}
15
+ className="w-6 h-6 rounded border border-border-default bg-bg-2 cursor-pointer p-0.5 shrink-0"
16
+ />
17
+ <input
18
+ type="text"
19
+ value={value}
20
+ onChange={(e) => {
21
+ const v = e.target.value
22
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
23
+ }}
24
+ onBlur={(e) => {
25
+ const v = e.target.value
26
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
27
+ }}
28
+ className="w-[72px] px-1.5 py-1 rounded border border-border-default bg-bg-2 text-text-1 text-[10px] font-mono outline-none focus:border-green"
29
+ />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ function ColorSection({ title, colors, defaultOpen = false }: {
35
+ title: string
36
+ colors: { key: keyof ThemeConfig; label: string }[]
37
+ defaultOpen?: boolean
38
+ }) {
39
+ const [open, setOpen] = useState(defaultOpen)
40
+ const theme = useConfigStore((s) => s.config.theme)
41
+ const previewTheme = useConfigStore((s) => s.previewTheme)
42
+ const updateTheme = useConfigStore((s) => s.updateTheme)
43
+ const resolved = useMemo(() => resolveTheme(theme), [theme])
44
+
45
+ return (
46
+ <div className="border border-border-default rounded-lg overflow-hidden">
47
+ <button
48
+ onClick={() => setOpen(!open)}
49
+ className="w-full flex items-center justify-between px-3 py-2 bg-bg-2 hover:bg-bg-3 transition-colors text-left"
50
+ >
51
+ <div className="flex items-center gap-2">
52
+ <span className="text-[11px] font-semibold">{title}</span>
53
+ <div className="flex gap-0.5">
54
+ {colors.slice(0, 4).map((c) => (
55
+ <div
56
+ key={c.key}
57
+ className="w-3 h-3 rounded-sm border border-border-subtle"
58
+ style={{ backgroundColor: resolved[c.key] as string }}
59
+ />
60
+ ))}
61
+ </div>
62
+ </div>
63
+ <ChevronDown size={12} className={`text-text-3 transition-transform ${open ? 'rotate-180' : ''}`} />
64
+ </button>
65
+ {open && (
66
+ <div className="px-3 py-2.5 space-y-2.5 bg-bg-1">
67
+ {colors.map((c) => (
68
+ <div key={c.key} className="flex items-center justify-between">
69
+ <span className="text-[10.5px] text-text-2">{c.label}</span>
70
+ <ColorInput
71
+ value={resolved[c.key] as string}
72
+ onInput={(v) => previewTheme({ [c.key]: v })}
73
+ onChange={(v) => updateTheme({ [c.key]: v })}
74
+ />
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
82
+
83
+ export function DesignPanel() {
84
+ const theme = useConfigStore((s) => s.config.theme)
85
+ const setTheme = useConfigStore((s) => s.setTheme)
86
+ const updateTheme = useConfigStore((s) => s.updateTheme)
87
+ const resolved = useMemo(() => resolveTheme(theme), [theme])
88
+
89
+ const activePresetId = useMemo(() => {
90
+ for (const preset of themePresets) {
91
+ const match = Object.keys(preset.theme).every(
92
+ (k) => resolved[k as keyof ThemeConfig] === preset.theme[k as keyof ThemeConfig]
93
+ )
94
+ if (match) return preset.id
95
+ }
96
+ return null
97
+ }, [resolved])
98
+
99
+ return (
100
+ <div className="px-3.5 py-3.5">
101
+ {/* Preset grid */}
102
+ <div className="mb-4">
103
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Presets</div>
104
+ <div className="grid grid-cols-2 gap-1.5">
105
+ {themePresets.map((preset) => (
106
+ <button
107
+ key={preset.id}
108
+ onClick={() => setTheme(preset.theme)}
109
+ className={`p-2 rounded-lg border transition-all text-left ${
110
+ activePresetId === preset.id
111
+ ? 'border-green bg-green/5'
112
+ : 'border-border-default bg-bg-2 hover:border-border-hover hover:bg-bg-3'
113
+ }`}
114
+ >
115
+ <div className="flex gap-0.5 mb-1.5">
116
+ <div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.bg0 }} />
117
+ <div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.bg2 }} />
118
+ <div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.accent }} />
119
+ <div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.text0 }} />
120
+ </div>
121
+ <div className="text-[10px] font-medium truncate">{preset.name}</div>
122
+ </button>
123
+ ))}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Color sections */}
128
+ <div className="space-y-2 mb-4">
129
+ <ColorSection
130
+ title="Backgrounds"
131
+ defaultOpen
132
+ colors={[
133
+ { key: 'bg0', label: 'Base' },
134
+ { key: 'bg1', label: 'Surface 1' },
135
+ { key: 'bg2', label: 'Surface 2' },
136
+ { key: 'bg3', label: 'Surface 3' },
137
+ { key: 'bg4', label: 'Surface 4' },
138
+ { key: 'bg5', label: 'Surface 5' },
139
+ ]}
140
+ />
141
+ <ColorSection
142
+ title="Text"
143
+ colors={[
144
+ { key: 'text0', label: 'Primary' },
145
+ { key: 'text1', label: 'Secondary' },
146
+ { key: 'text2', label: 'Muted' },
147
+ { key: 'text3', label: 'Dimmed' },
148
+ ]}
149
+ />
150
+ <ColorSection
151
+ title="Accent"
152
+ defaultOpen
153
+ colors={[
154
+ { key: 'accent', label: 'Accent' },
155
+ { key: 'accentDim', label: 'Accent Dim' },
156
+ ]}
157
+ />
158
+ <ColorSection
159
+ title="Borders"
160
+ colors={[
161
+ { key: 'borderDefault', label: 'Default' },
162
+ { key: 'borderSubtle', label: 'Subtle' },
163
+ { key: 'borderHover', label: 'Hover' },
164
+ ]}
165
+ />
166
+ </div>
167
+
168
+ {/* Fonts */}
169
+ <div className="mb-4">
170
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Fonts</div>
171
+ <div className="space-y-2.5">
172
+ {([
173
+ { key: 'fontSans' as const, label: 'Body' },
174
+ { key: 'fontDisplay' as const, label: 'Display' },
175
+ { key: 'fontMono' as const, label: 'Mono' },
176
+ ]).map(({ key, label }) => (
177
+ <div key={key}>
178
+ <label className="block text-[10.5px] text-text-2 mb-1">{label}</label>
179
+ <select
180
+ value={resolved[key]}
181
+ onChange={(e) => updateTheme({ [key]: e.target.value })}
182
+ className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green cursor-pointer"
183
+ style={{ fontFamily: `"${resolved[key]}", sans-serif` }}
184
+ >
185
+ {googleFontOptions.map((f) => (
186
+ <option key={f} value={f}>{f}</option>
187
+ ))}
188
+ </select>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+
194
+ {/* Radius */}
195
+ <div>
196
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Radius</div>
197
+ <div className="grid grid-cols-2 gap-2">
198
+ <div>
199
+ <label className="block text-[10.5px] text-text-2 mb-1">Default</label>
200
+ <input
201
+ type="number"
202
+ min={0}
203
+ max={24}
204
+ value={resolved.radius}
205
+ onChange={(e) => updateTheme({ radius: Number(e.target.value) })}
206
+ className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green"
207
+ />
208
+ </div>
209
+ <div>
210
+ <label className="block text-[10.5px] text-text-2 mb-1">Large</label>
211
+ <input
212
+ type="number"
213
+ min={0}
214
+ max={32}
215
+ value={resolved.radiusLg}
216
+ onChange={(e) => updateTheme({ radiusLg: Number(e.target.value) })}
217
+ className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green"
218
+ />
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ )
224
+ }
@@ -0,0 +1,196 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { toast } from 'sonner'
3
+ import { FolderOpen, Layers, Briefcase, UtensilsCrossed, Building2, BookOpen } from 'lucide-react'
4
+ import { CanvasToolbar } from './CanvasToolbar'
5
+ import { LeftSidebar } from './LeftSidebar'
6
+ import { Canvas } from './Canvas'
7
+ import { RightSidebar } from './RightSidebar'
8
+ import { JsonDrawer } from './JsonDrawer'
9
+ import { VersionHistory } from './VersionHistory'
10
+ import { GenerationOverlay } from './GenerationOverlay'
11
+ import { useConfigStore } from '@/store/configStore'
12
+ import { useEditorStore } from '@/store/editorStore'
13
+ import { generateSiteConfig } from '@/lib/generate-site'
14
+ import { templateMeta, buildTemplate } from '@/lib/templates'
15
+ import { hexToRgb } from '@/lib/theme-presets'
16
+ import type { SiteConfig } from '@/blocks/types'
17
+ import type { ExportSiteSettings } from '@/lib/export-html'
18
+
19
+ const templateIcons: Record<string, typeof Briefcase> = {
20
+ Briefcase, UtensilsCrossed, Building2, BookOpen,
21
+ }
22
+
23
+ export interface EditorLayoutProps {
24
+ onConfigChange?: (id: string, config: SiteConfig) => void
25
+ onRename?: (id: string, name: string) => void
26
+ onCreate?: (name: string) => string
27
+ activeProject?: { name: string; settings?: ExportSiteSettings }
28
+ onExit?: () => void
29
+ onServerFallback?: (prompt: string, signal?: AbortSignal) => Promise<SiteConfig>
30
+ }
31
+
32
+ function useAutoSaveToProject(onConfigChange?: EditorLayoutProps['onConfigChange']) {
33
+ const config = useConfigStore((s) => s.config)
34
+ const activeProjectId = useEditorStore((s) => s.activeProjectId)
35
+ const loadedConfigRef = useRef<string | null>(null)
36
+
37
+ // Snapshot the config at load time so we can diff
38
+ useEffect(() => {
39
+ loadedConfigRef.current = JSON.stringify(config)
40
+ }, [activeProjectId]) // eslint-disable-line react-hooks/exhaustive-deps
41
+
42
+ useEffect(() => {
43
+ if (!activeProjectId) return
44
+ const serialized = JSON.stringify(config)
45
+ // Only save when config actually differs from what was loaded
46
+ if (serialized === loadedConfigRef.current) return
47
+ onConfigChange?.(activeProjectId, config)
48
+ }, [config, activeProjectId]) // eslint-disable-line react-hooks/exhaustive-deps
49
+ }
50
+
51
+ function useGenerationOrchestration(onConfigChange?: EditorLayoutProps['onConfigChange'], onRename?: EditorLayoutProps['onRename'], onServerFallback?: EditorLayoutProps['onServerFallback']) {
52
+ const isGenerating = useEditorStore((s) => s.isGenerating)
53
+ const generationPrompt = useEditorStore((s) => s.generationPrompt)
54
+ const clearGeneration = useEditorStore((s) => s.clearGeneration)
55
+ const setGenerationError = useEditorStore((s) => s.setGenerationError)
56
+ const activeProjectId = useEditorStore((s) => s.activeProjectId)
57
+ const setConfig = useConfigStore((s) => s.setConfig)
58
+ const abortRef = useRef<AbortController | null>(null)
59
+
60
+ useEffect(() => {
61
+ if (!isGenerating || !generationPrompt) return
62
+
63
+ const controller = new AbortController()
64
+ abortRef.current = controller
65
+
66
+ // Timeout after 30s to prevent infinite loading
67
+ const timeout = setTimeout(() => {
68
+ controller.abort()
69
+ setGenerationError('Generation timed out')
70
+ toast.error('Generation timed out. Try again or add a Gemini API key in Settings.')
71
+ clearGeneration()
72
+ }, 30000)
73
+
74
+ generateSiteConfig(generationPrompt, controller.signal, onServerFallback)
75
+ .then(({ config, source }) => {
76
+ clearTimeout(timeout)
77
+ if (controller.signal.aborted) return
78
+ setConfig(config)
79
+ if (activeProjectId) {
80
+ onConfigChange?.(activeProjectId, config)
81
+ if (config.name) onRename?.(activeProjectId, config.name)
82
+ }
83
+ clearGeneration()
84
+ if (source === 'template') {
85
+ toast('Generated from template. Add a Gemini API key in Settings for AI generation.')
86
+ }
87
+ })
88
+ .catch((err) => {
89
+ clearTimeout(timeout)
90
+ if (err instanceof Error && err.name === 'AbortError') return
91
+ setGenerationError(err instanceof Error ? err.message : 'Generation failed')
92
+ toast.error(err instanceof Error ? err.message : 'Generation failed')
93
+ clearGeneration()
94
+ })
95
+
96
+ return () => {
97
+ clearTimeout(timeout)
98
+ controller.abort()
99
+ abortRef.current = null
100
+ }
101
+ }, [isGenerating, generationPrompt]) // eslint-disable-line react-hooks/exhaustive-deps
102
+ }
103
+
104
+ function EditorEmptyState({ onCreate, onExit }: Pick<EditorLayoutProps, 'onCreate' | 'onExit'>) {
105
+ const setConfig = useConfigStore((s) => s.setConfig)
106
+ const setActiveProject = useEditorStore((s) => s.setActiveProject)
107
+
108
+ function startFromTemplate(tplId: string, tplName: string) {
109
+ const id = onCreate?.(tplName) ?? `local-${Date.now()}` // eslint-disable-line react-hooks/purity
110
+ setActiveProject(id)
111
+ setConfig(buildTemplate(tplId, tplName))
112
+ }
113
+
114
+ return (
115
+ <div className="h-full flex items-center justify-center">
116
+ <div className="flex flex-col items-center text-center px-6 max-w-md">
117
+ <div className="w-12 h-12 rounded-xl bg-bg-3 border border-border-default flex items-center justify-center mb-4">
118
+ <FolderOpen size={20} className="text-text-3" />
119
+ </div>
120
+ <h2 className="text-[16px] font-display font-semibold text-text-1 mb-1">No project selected</h2>
121
+ <p className="text-text-2 text-[13px] mb-6">Open a project from the Dashboard, or start from a template.</p>
122
+
123
+ <button
124
+ onClick={() => onExit?.()}
125
+ className="px-5 py-2 rounded-xl bg-green text-black text-[13px] font-semibold hover:bg-green-dim active:scale-[0.97] transition-all mb-6"
126
+ >
127
+ Go to Dashboard
128
+ </button>
129
+
130
+ <div className="grid grid-cols-2 gap-2 w-full">
131
+ {templateMeta.map((tpl) => {
132
+ const rgb = hexToRgb(tpl.accent)
133
+ const Icon = templateIcons[tpl.icon] || Layers
134
+ return (
135
+ <button
136
+ key={tpl.id}
137
+ onClick={() => startFromTemplate(tpl.id, tpl.name)}
138
+ className="group relative bg-bg-1 border border-border-default rounded-lg p-3 text-left transition-all hover:border-border-hover card-lift hover:card-lift-hover active:scale-[0.97]"
139
+ >
140
+ <div
141
+ className="absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
142
+ style={{ background: `rgba(${rgb}, 0.06)` }}
143
+ />
144
+ <div className="relative">
145
+ <div className="flex items-center gap-2 mb-1.5">
146
+ <div
147
+ className="w-6 h-6 rounded flex items-center justify-center shrink-0 transition-all opacity-70 group-hover:opacity-100 group-hover:scale-110"
148
+ style={{ background: `rgba(${rgb}, 0.12)`, color: tpl.accent }}
149
+ >
150
+ <Icon size={12} />
151
+ </div>
152
+ <div className="text-[11.5px] font-semibold text-text-0">{tpl.name}</div>
153
+ </div>
154
+ <div className="text-[10px] text-text-2 leading-snug mb-1.5">{tpl.description}</div>
155
+ <div className="text-[10px] text-text-3 flex items-center gap-1">
156
+ <Layers size={9} />
157
+ {tpl.blockCount} blocks
158
+ </div>
159
+ </div>
160
+ </button>
161
+ )
162
+ })}
163
+ </div>
164
+ </div>
165
+ </div>
166
+ )
167
+ }
168
+
169
+ export function EditorLayout({ onConfigChange, onRename, onCreate, activeProject, onExit, onServerFallback }: EditorLayoutProps = {}) {
170
+ useAutoSaveToProject(onConfigChange)
171
+ useGenerationOrchestration(onConfigChange, onRename, onServerFallback)
172
+ const previewMode = useEditorStore((s) => s.previewMode)
173
+ const activeProjectId = useEditorStore((s) => s.activeProjectId)
174
+
175
+ if (!activeProjectId) {
176
+ return <EditorEmptyState onCreate={onCreate} onExit={onExit} />
177
+ }
178
+
179
+ return (
180
+ <div className="h-full flex flex-col relative">
181
+ <div className="flex-1 flex overflow-hidden">
182
+ {!previewMode && <LeftSidebar />}
183
+ <div className="flex-1 flex flex-col min-w-0 relative">
184
+ <CanvasToolbar activeProject={activeProject} onExit={onExit} />
185
+ <div className="flex-1 flex flex-col overflow-hidden relative">
186
+ <Canvas />
187
+ <JsonDrawer />
188
+ <GenerationOverlay />
189
+ </div>
190
+ </div>
191
+ {!previewMode && <RightSidebar />}
192
+ </div>
193
+ <VersionHistory />
194
+ </div>
195
+ )
196
+ }
@@ -0,0 +1,201 @@
1
+ import { useEffect, useReducer } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { useEditorStore } from '@/store/editorStore'
4
+
5
+ const steps = [
6
+ { label: 'Analyzing prompt', duration: 3 },
7
+ { label: 'Generating layout', duration: 5 },
8
+ { label: 'Writing copy', duration: 4 },
9
+ { label: 'Applying theme', duration: 3 },
10
+ ]
11
+
12
+ type OverlayState = {
13
+ elapsed: number
14
+ showAfterFade: boolean
15
+ }
16
+
17
+ type OverlayAction =
18
+ | { type: 'start' }
19
+ | { type: 'tick' }
20
+ | { type: 'hide' }
21
+
22
+ function overlayReducer(state: OverlayState, action: OverlayAction): OverlayState {
23
+ switch (action.type) {
24
+ case 'start':
25
+ return { elapsed: 0, showAfterFade: true }
26
+ case 'tick':
27
+ return state.showAfterFade ? { ...state, elapsed: state.elapsed + 1 } : state
28
+ case 'hide':
29
+ return { ...state, showAfterFade: false }
30
+ default:
31
+ return state
32
+ }
33
+ }
34
+
35
+ export function GenerationOverlay() {
36
+ const isGenerating = useEditorStore((s) => s.isGenerating)
37
+ const clearGeneration = useEditorStore((s) => s.clearGeneration)
38
+ const [{ elapsed, showAfterFade }, dispatch] = useReducer(overlayReducer, {
39
+ elapsed: 0,
40
+ showAfterFade: false,
41
+ })
42
+
43
+ useEffect(() => {
44
+ if (isGenerating) {
45
+ dispatch({ type: 'start' })
46
+ const timer = setInterval(() => dispatch({ type: 'tick' }), 1000)
47
+ return () => clearInterval(timer)
48
+ }
49
+
50
+ const timer = setTimeout(() => dispatch({ type: 'hide' }), 600)
51
+ return () => clearTimeout(timer)
52
+ }, [isGenerating])
53
+
54
+ const visible = isGenerating || showAfterFade
55
+ const fading = !isGenerating && showAfterFade
56
+
57
+ if (!visible) return null
58
+
59
+ // Determine active step based on elapsed time
60
+ let stepTime = 0
61
+ let activeStep = steps.length - 1
62
+ for (let i = 0; i < steps.length; i++) {
63
+ stepTime += steps[i].duration
64
+ if (elapsed < stepTime) { activeStep = i; break }
65
+ }
66
+
67
+ return (
68
+ <div
69
+ className={`absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-600 ${
70
+ fading ? 'opacity-0' : 'opacity-100'
71
+ }`}
72
+ >
73
+ {/* Backdrop */}
74
+ <div className="absolute inset-0 bg-bg-0/90 backdrop-blur-md" />
75
+
76
+ {/* Animated glow orb */}
77
+ <div
78
+ className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-[100px]"
79
+ style={{
80
+ background: 'radial-gradient(circle, var(--color-green) 0%, transparent 70%)',
81
+ animation: 'gen-orb 4s ease-in-out infinite',
82
+ }}
83
+ />
84
+
85
+ <div className="relative flex flex-col items-center gap-8 max-w-md px-8">
86
+ {/* Wireframe animation */}
87
+ <div className="relative w-[200px] h-[130px] rounded-lg border border-border-default bg-bg-1/80 overflow-hidden">
88
+ {/* Animated wireframe blocks */}
89
+ <div className="absolute top-0 left-0 right-0 h-4 bg-bg-3/60 flex items-center px-2 gap-1">
90
+ <div className="w-6 h-1.5 rounded-sm bg-green/40" />
91
+ <div className="flex-1" />
92
+ <div className="w-3 h-1.5 rounded-sm bg-bg-4" />
93
+ <div className="w-3 h-1.5 rounded-sm bg-bg-4" />
94
+ <div className="w-3 h-1.5 rounded-sm bg-bg-4" />
95
+ </div>
96
+ <div className="absolute top-6 left-3 right-3 space-y-1.5">
97
+ <div className="h-2 w-16 rounded-sm bg-green/30 gen-shimmer" style={{ animationDelay: '0ms' }} />
98
+ <div className="h-4 w-full rounded-sm bg-bg-4/80 gen-shimmer" style={{ animationDelay: '100ms' }} />
99
+ <div className="h-2 w-3/4 rounded-sm bg-bg-4/50 gen-shimmer" style={{ animationDelay: '200ms' }} />
100
+ <div className="flex gap-1.5 pt-1">
101
+ <div className="h-3 w-12 rounded-sm bg-green/40 gen-shimmer" style={{ animationDelay: '300ms' }} />
102
+ <div className="h-3 w-10 rounded-sm bg-bg-4/60 gen-shimmer" style={{ animationDelay: '350ms' }} />
103
+ </div>
104
+ </div>
105
+ <div className="absolute bottom-2 left-3 right-3 flex gap-1.5">
106
+ <div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '400ms' }} />
107
+ <div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '500ms' }} />
108
+ <div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '600ms' }} />
109
+ </div>
110
+ {/* Scan line */}
111
+ <div className="absolute left-0 right-0 h-px bg-green/40 gen-scan" />
112
+ </div>
113
+
114
+ {/* Status */}
115
+ <div className="text-center">
116
+ <p className="text-text-0 text-[16px] font-display font-semibold mb-2 tracking-tight">
117
+ Building your site
118
+ </p>
119
+ <div className="flex items-center justify-center gap-2 text-green text-[13px] tabular-nums">
120
+ <div className="w-4 h-4 rounded-full border-2 border-green/30 border-t-green animate-spin" />
121
+ <span>{elapsed}s</span>
122
+ </div>
123
+ </div>
124
+
125
+ {/* Progress steps */}
126
+ <div className="w-full space-y-2">
127
+ {steps.map((step, i) => (
128
+ <div key={step.label} className="flex items-center gap-3">
129
+ <div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold border transition-all duration-500 ${
130
+ i < activeStep
131
+ ? 'bg-green/20 border-green/40 text-green'
132
+ : i === activeStep
133
+ ? 'border-green text-green animate-pulse'
134
+ : 'border-border-default text-text-3'
135
+ }`}>
136
+ {i < activeStep ? (
137
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
138
+ <path d="M2 5L4 7L8 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
139
+ </svg>
140
+ ) : (
141
+ <span>{i + 1}</span>
142
+ )}
143
+ </div>
144
+ <span className={`text-[12px] transition-colors duration-500 ${
145
+ i < activeStep ? 'text-text-2' : i === activeStep ? 'text-text-0 font-medium' : 'text-text-3'
146
+ }`}>
147
+ {step.label}
148
+ </span>
149
+ {i === activeStep && (
150
+ <div className="flex-1 h-1 rounded-full bg-bg-3 overflow-hidden ml-auto max-w-[80px]">
151
+ <div className="h-full bg-green/60 rounded-full gen-progress" />
152
+ </div>
153
+ )}
154
+ </div>
155
+ ))}
156
+ </div>
157
+
158
+ {/* Cancel */}
159
+ <button
160
+ onClick={() => clearGeneration()}
161
+ className="px-4 py-2 rounded-lg bg-bg-2 text-text-2 text-[12px] border border-border-default hover:bg-bg-3 hover:text-text-0 hover:border-border-hover transition-all inline-flex items-center gap-1.5"
162
+ >
163
+ <X size={12} />
164
+ Cancel
165
+ </button>
166
+ </div>
167
+
168
+ <style>{`
169
+ @keyframes gen-orb {
170
+ 0%, 100% { transform: translate(-30%, -20%) scale(1); }
171
+ 33% { transform: translate(20%, -10%) scale(1.1); }
172
+ 66% { transform: translate(-10%, 20%) scale(0.9); }
173
+ }
174
+ .gen-shimmer {
175
+ animation: gen-shimmer 2s ease-in-out infinite;
176
+ }
177
+ @keyframes gen-shimmer {
178
+ 0%, 100% { opacity: 0.4; }
179
+ 50% { opacity: 0.8; }
180
+ }
181
+ .gen-scan {
182
+ animation: gen-scan 2.5s ease-in-out infinite;
183
+ }
184
+ @keyframes gen-scan {
185
+ 0% { top: 0; opacity: 0; }
186
+ 10% { opacity: 1; }
187
+ 90% { opacity: 1; }
188
+ 100% { top: 100%; opacity: 0; }
189
+ }
190
+ .gen-progress {
191
+ animation: gen-progress 3s ease-in-out infinite;
192
+ }
193
+ @keyframes gen-progress {
194
+ 0% { width: 0%; }
195
+ 50% { width: 80%; }
196
+ 100% { width: 100%; }
197
+ }
198
+ `}</style>
199
+ </div>
200
+ )
201
+ }