@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,203 @@
1
+ import { useState } from 'react'
2
+ import { ChevronLeft, ChevronRight, Star, Quote } from 'lucide-react'
3
+ import type { BlockConfig } from '../types'
4
+
5
+ interface Testimonial {
6
+ name: string
7
+ role: string
8
+ quote: string
9
+ rating?: number
10
+ avatar?: string
11
+ }
12
+
13
+ interface TestimonialsProps {
14
+ title?: string
15
+ subtitle?: string
16
+ items?: Testimonial[]
17
+ }
18
+
19
+ const defaultTestimonials: Testimonial[] = [
20
+ { name: 'Sarah Chen', role: 'CEO at TechCorp', quote: 'OpenPage completely changed how we build landing pages. The JSON config approach is genius.', rating: 5 },
21
+ { name: 'Marcus Johnson', role: 'Lead Developer', quote: 'Finally, a tool where both designers and AI agents can work together seamlessly.', rating: 5 },
22
+ { name: 'Emma Wilson', role: 'Product Manager', quote: 'We shipped our marketing site in half the time. The component library is incredible.', rating: 4 },
23
+ ]
24
+
25
+ function StarRating({ rating }: { rating: number }) {
26
+ return (
27
+ <div className="flex gap-0.5">
28
+ {[1, 2, 3, 4, 5].map((i) => (
29
+ <Star
30
+ key={i}
31
+ size={12}
32
+ className={i <= rating ? 'text-status-yellow fill-status-yellow' : 'text-text-3'}
33
+ />
34
+ ))}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ function TestimonialsCards({ props }: { props: TestimonialsProps }) {
40
+ const items = props.items || defaultTestimonials
41
+
42
+ return (
43
+ <section className="px-6 @md:px-10 py-16 @md:py-20">
44
+ <div className="reveal-fade-up reveal-d1 text-center mb-10">
45
+ <h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
46
+ {props.title || 'What people say'}
47
+ </h2>
48
+ {props.subtitle && (
49
+ <p className="text-text-2 text-sm max-w-lg mx-auto">{props.subtitle}</p>
50
+ )}
51
+ </div>
52
+
53
+ <div className="grid grid-cols-1 @2xl:grid-cols-3 gap-4">
54
+ {items.map((item, i) => (
55
+ <div
56
+ key={i}
57
+ className={`reveal-fade-up reveal-d${Math.min(i + 2, 8)} bg-bg-2 border border-border-default rounded-xl p-5 transition-all hover:border-border-hover`}
58
+ >
59
+ <Quote size={20} className="text-green/30 mb-3" />
60
+ <p className="text-[13px] text-text-1 leading-relaxed mb-4 italic">
61
+ "{item.quote}"
62
+ </p>
63
+ {item.rating && <StarRating rating={item.rating} />}
64
+ <div className="flex items-center gap-3 mt-4 pt-4 border-t border-border-subtle">
65
+ {item.avatar ? (
66
+ <img src={item.avatar} alt={item.name} className="w-9 h-9 rounded-full object-cover border border-border-default" />
67
+ ) : (
68
+ <div className="w-9 h-9 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-[11px] font-semibold text-text-2">
69
+ {item.name.split(' ').map(n => n[0]).join('')}
70
+ </div>
71
+ )}
72
+ <div>
73
+ <div className="text-[12.5px] font-semibold">{item.name}</div>
74
+ <div className="text-[11px] text-text-3">{item.role}</div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ ))}
79
+ </div>
80
+ </section>
81
+ )
82
+ }
83
+
84
+ function TestimonialsCarousel({ props }: { props: TestimonialsProps }) {
85
+ const items = props.items || defaultTestimonials
86
+ const [activeIndex, setActiveIndex] = useState(0)
87
+
88
+ const goTo = (index: number) => {
89
+ setActiveIndex(((index % items.length) + items.length) % items.length)
90
+ }
91
+
92
+ if (items.length === 0) return null
93
+ const safeIndex = Math.min(activeIndex, items.length - 1)
94
+ const item = items[safeIndex]
95
+
96
+ return (
97
+ <section className="px-6 @md:px-10 py-16 @md:py-20">
98
+ <div className="reveal-fade-up reveal-d1 text-center mb-10">
99
+ <h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
100
+ {props.title || 'What people say'}
101
+ </h2>
102
+ </div>
103
+
104
+ <div className="reveal-fade-up reveal-d2 max-w-2xl mx-auto">
105
+ <div className="text-center">
106
+ <Quote size={32} className="text-green/20 mx-auto mb-4" />
107
+ <p className="text-lg text-text-0 leading-relaxed mb-4 italic">
108
+ "{item.quote}"
109
+ </p>
110
+ {item.rating && (
111
+ <div className="flex justify-center mb-4">
112
+ <StarRating rating={item.rating} />
113
+ </div>
114
+ )}
115
+ <div className="flex items-center justify-center gap-3">
116
+ {item.avatar ? (
117
+ <img src={item.avatar} alt={item.name} className="w-10 h-10 rounded-full object-cover border border-border-default" />
118
+ ) : (
119
+ <div className="w-10 h-10 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-xs font-semibold text-text-2">
120
+ {item.name.split(' ').map(n => n[0]).join('')}
121
+ </div>
122
+ )}
123
+ <div className="text-left">
124
+ <div className="text-sm font-semibold">{item.name}</div>
125
+ <div className="text-[11px] text-text-3">{item.role}</div>
126
+ </div>
127
+ </div>
128
+
129
+ {/* Navigation */}
130
+ <div className="flex items-center justify-center gap-4 mt-6">
131
+ <button
132
+ onClick={() => goTo(safeIndex - 1)}
133
+ className="w-8 h-8 rounded-full border border-border-default bg-bg-2 flex items-center justify-center text-text-2 hover:text-text-0 hover:border-border-hover transition-colors"
134
+ >
135
+ <ChevronLeft size={16} />
136
+ </button>
137
+ <div className="flex gap-1.5">
138
+ {items.map((_, i) => (
139
+ <button
140
+ key={i}
141
+ onClick={() => goTo(i)}
142
+ className={`w-2 h-2 rounded-full transition-colors ${
143
+ i === safeIndex ? 'bg-green' : 'bg-bg-4 hover:bg-bg-5'
144
+ }`}
145
+ />
146
+ ))}
147
+ </div>
148
+ <button
149
+ onClick={() => goTo(safeIndex + 1)}
150
+ className="w-8 h-8 rounded-full border border-border-default bg-bg-2 flex items-center justify-center text-text-2 hover:text-text-0 hover:border-border-hover transition-colors"
151
+ >
152
+ <ChevronRight size={16} />
153
+ </button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </section>
158
+ )
159
+ }
160
+
161
+ function TestimonialsSpotlight({ props }: { props: TestimonialsProps }) {
162
+ const items = props.items || defaultTestimonials
163
+ const item = items[0]
164
+ if (!item) return null
165
+
166
+ return (
167
+ <section className="px-6 @md:px-10 py-16 @md:py-24">
168
+ <div className="max-w-2xl mx-auto text-center">
169
+ <div className="reveal-fade-up reveal-d1"><Quote size={48} className="text-green/20 mx-auto mb-6" /></div>
170
+ <p className="reveal-fade-up reveal-d2 text-xl @md:text-2xl text-text-0 leading-relaxed mb-8 italic font-display">
171
+ "{item.quote}"
172
+ </p>
173
+ {item.rating && (
174
+ <div className="flex justify-center mb-6">
175
+ <StarRating rating={item.rating} />
176
+ </div>
177
+ )}
178
+ <div className="flex items-center justify-center gap-3">
179
+ <div className="w-12 h-12 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-sm font-semibold text-text-2">
180
+ {item.name.split(' ').map(n => n[0]).join('')}
181
+ </div>
182
+ <div className="text-left">
183
+ <div className="text-sm font-semibold">{item.name}</div>
184
+ <div className="text-[12px] text-text-3">{item.role}</div>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </section>
189
+ )
190
+ }
191
+
192
+ export function TestimonialsBlock({ block }: { block: BlockConfig }) {
193
+ const props = block.props as unknown as TestimonialsProps
194
+
195
+ switch (block.variant) {
196
+ case 'carousel':
197
+ return <TestimonialsCarousel props={props} />
198
+ case 'spotlight':
199
+ return <TestimonialsSpotlight props={props} />
200
+ default:
201
+ return <TestimonialsCards props={props} />
202
+ }
203
+ }
@@ -0,0 +1,72 @@
1
+ export type BlockType =
2
+ | 'navbar'
3
+ | 'hero'
4
+ | 'features'
5
+ | 'pricing'
6
+ | 'cta'
7
+ | 'footer'
8
+ | 'testimonials'
9
+ | 'stats'
10
+ | 'faq'
11
+ | 'team'
12
+ | 'contact'
13
+ | 'newsletter'
14
+ | 'logocloud'
15
+ | 'divider'
16
+ | 'banner'
17
+ | 'content'
18
+ | 'image'
19
+ | 'video'
20
+ | 'gallery'
21
+
22
+ export type BlockVariant = string
23
+
24
+ export interface BlockConfig {
25
+ id: string
26
+ type: BlockType
27
+ variant: BlockVariant
28
+ props: Record<string, unknown>
29
+ }
30
+
31
+ export interface ThemeConfig {
32
+ // Backgrounds
33
+ bg0: string
34
+ bg1: string
35
+ bg2: string
36
+ bg3: string
37
+ bg4: string
38
+ bg5: string
39
+ // Text
40
+ text0: string
41
+ text1: string
42
+ text2: string
43
+ text3: string
44
+ // Accent
45
+ accent: string
46
+ accentDim: string
47
+ // Borders
48
+ borderDefault: string
49
+ borderSubtle: string
50
+ borderHover: string
51
+ // Fonts
52
+ fontSans: string
53
+ fontDisplay: string
54
+ fontMono: string
55
+ // Radius
56
+ radius: number
57
+ radiusLg: number
58
+ }
59
+
60
+ export interface PageConfig {
61
+ id: string
62
+ name: string
63
+ path: string
64
+ blocks: BlockConfig[]
65
+ }
66
+
67
+ export interface SiteConfig {
68
+ name: string
69
+ pages?: PageConfig[]
70
+ blocks: BlockConfig[]
71
+ theme?: Partial<ThemeConfig>
72
+ }
@@ -0,0 +1,58 @@
1
+ import { Play } from 'lucide-react'
2
+ import type { BlockConfig } from '../types'
3
+
4
+ function extractYouTubeId(url: string): string | null {
5
+ const match = url.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/))([a-zA-Z0-9_-]{11})/)
6
+ return match?.[1] || null
7
+ }
8
+
9
+ function extractVimeoId(url: string): string | null {
10
+ const match = url.match(/vimeo\.com\/(\d+)/)
11
+ return match?.[1] || null
12
+ }
13
+
14
+ function Placeholder() {
15
+ return (
16
+ <div className="w-full aspect-video bg-gradient-to-br from-bg-3 to-bg-4 rounded-lg flex items-center justify-center">
17
+ <div className="w-14 h-14 rounded-full bg-bg-5/50 border border-border-default flex items-center justify-center">
18
+ <Play size={24} className="text-text-3 ml-0.5" />
19
+ </div>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export function VideoBlock({ block }: { block: BlockConfig }) {
25
+ const { variant, props } = block
26
+ const url = (props.url as string) || ''
27
+ const title = props.title as string | undefined
28
+
29
+ let embedUrl: string | null = null
30
+
31
+ if (variant === 'vimeo') {
32
+ const id = extractVimeoId(url)
33
+ if (id) embedUrl = `https://player.vimeo.com/video/${id}`
34
+ } else {
35
+ // youtube (default)
36
+ const id = extractYouTubeId(url)
37
+ if (id) embedUrl = `https://www.youtube-nocookie.com/embed/${id}`
38
+ }
39
+
40
+ return (
41
+ <div className="px-6 py-12 @lg:px-16 @lg:py-16">
42
+ {title && <h2 className="reveal-fade-up reveal-d1 text-xl font-display font-semibold mb-4 text-center">{title}</h2>}
43
+ {embedUrl ? (
44
+ <div className="reveal-scale reveal-d2 w-full aspect-video rounded-lg overflow-hidden border border-border-default">
45
+ <iframe
46
+ src={embedUrl}
47
+ title={title || 'Video'}
48
+ className="w-full h-full"
49
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
50
+ allowFullScreen
51
+ />
52
+ </div>
53
+ ) : (
54
+ <Placeholder />
55
+ )}
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,318 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { Send, Check, X } from 'lucide-react'
3
+ import { toast } from 'sonner'
4
+ import { useConfigStore } from '@/store/configStore'
5
+ import { blockMetadata } from '@/lib/block-metadata'
6
+ import { themePresets } from '@/lib/theme-presets'
7
+ import type { BlockConfig } from '@/blocks/types'
8
+
9
+ interface ChatMessage {
10
+ id: string
11
+ role: 'user' | 'agent'
12
+ text: string
13
+ applied?: boolean
14
+ patch?: {
15
+ path: string
16
+ blockId?: string
17
+ propKey?: string
18
+ value?: string
19
+ added?: string[]
20
+ removed?: string[]
21
+ }
22
+ }
23
+
24
+ const initialMessages: ChatMessage[] = [
25
+ {
26
+ id: '1',
27
+ role: 'agent',
28
+ text: 'Hi! I can help you build and modify your site. Try asking me to add sections, change content, or tweak styles.',
29
+ },
30
+ ]
31
+
32
+ function TypingIndicator() {
33
+ return (
34
+ <div className="self-start flex gap-1 px-4 py-3 bg-green-glow rounded-xl rounded-bl-sm border border-green/10">
35
+ {[0, 1, 2].map((i) => (
36
+ <span
37
+ key={i}
38
+ className="w-1.5 h-1.5 rounded-full bg-green opacity-40"
39
+ style={{
40
+ animation: 'typeDot 1.4s infinite',
41
+ animationDelay: `${i * 0.2}s`,
42
+ }}
43
+ />
44
+ ))}
45
+ <style>{`
46
+ @keyframes typeDot {
47
+ 0%, 60%, 100% { opacity: 0.4; transform: translateY(0); }
48
+ 30% { opacity: 1; transform: translateY(-3px); }
49
+ }
50
+ `}</style>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ // Pattern matching for demo agent responses
56
+ function generateResponse(input: string, blocks: { id: string; type: string; variant: string; props: Record<string, unknown> }[]): ChatMessage | { action: 'addBlock'; block: BlockConfig; message: string } | { action: 'removeBlock'; blockId: string; message: string } | { action: 'changeVariant'; blockId: string; variant: string; message: string } | { action: 'changeTheme'; themeId: string; message: string } {
57
+ const lower = input.toLowerCase()
58
+ const heroBlock = blocks.find((b) => b.type === 'hero')
59
+
60
+ // Change headline
61
+ if (lower.includes('change') && lower.includes('headline') && heroBlock) {
62
+ const match = input.match(/["'](.+?)["']/) || input.match(/to\s+(.+)/i)
63
+ const newHeadline = match?.[1]?.trim() || 'Your New Headline'
64
+ const oldHeadline = String(heroBlock.props.headline || 'Build websites with JSON')
65
+ return {
66
+ id: `msg-${Date.now()}`,
67
+ role: 'agent',
68
+ text: `I'll update the hero headline for you.`,
69
+ patch: {
70
+ path: `blocks[${blocks.indexOf(heroBlock)}].props.headline`,
71
+ blockId: heroBlock.id,
72
+ propKey: 'headline',
73
+ value: newHeadline,
74
+ removed: [`"${oldHeadline}"`],
75
+ added: [`"${newHeadline}"`],
76
+ },
77
+ }
78
+ }
79
+
80
+ // Add block: "add a pricing section", "add FAQ", "add testimonials"
81
+ const addMatch = lower.match(/add\s+(?:a\s+)?(\w+)/)
82
+ if (addMatch) {
83
+ const blockType = addMatch[1].replace(/s$/, '') // strip trailing s
84
+ const meta = blockMetadata.find((b) => b.type === blockType || b.label.toLowerCase().includes(blockType))
85
+ if (meta) {
86
+ const block: BlockConfig = {
87
+ id: `block-${Date.now()}`,
88
+ type: meta.type,
89
+ variant: meta.variants[0],
90
+ props: { ...meta.defaultProps },
91
+ }
92
+ return { action: 'addBlock', block, message: `Adding a ${meta.label} block.` }
93
+ }
94
+ }
95
+
96
+ // Remove block: "remove the FAQ", "delete the pricing"
97
+ const removeMatch = lower.match(/(?:remove|delete)\s+(?:the\s+)?(\w+)/)
98
+ if (removeMatch) {
99
+ const blockType = removeMatch[1].replace(/s$/, '')
100
+ const found = blocks.find((b) => b.type === blockType || b.type.includes(blockType))
101
+ if (found) {
102
+ return { action: 'removeBlock', blockId: found.id, message: `Removing the ${found.type} block.` }
103
+ }
104
+ }
105
+
106
+ // Change variant: "make the hero a split layout", "change hero to minimal"
107
+ const variantMatch = lower.match(/(?:make|change|switch)\s+(?:the\s+)?(\w+)\s+(?:to\s+|a\s+)?(\w+)/)
108
+ if (variantMatch) {
109
+ const blockType = variantMatch[1]
110
+ const variant = variantMatch[2]
111
+ const found = blocks.find((b) => b.type === blockType || b.type.includes(blockType))
112
+ if (found) {
113
+ const meta = blockMetadata.find((b) => b.type === found.type)
114
+ const matchedVariant = meta?.variants.find((v) => v.includes(variant))
115
+ if (matchedVariant) {
116
+ return { action: 'changeVariant', blockId: found.id, variant: matchedVariant, message: `Changing ${found.type} to ${matchedVariant} variant.` }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Change theme: "make it blue", "switch to ocean theme", "use midnight theme"
122
+ const themeMatch = lower.match(/(?:make it|switch to|use|apply)\s+(?:the\s+)?(\w+)\s*(?:theme)?/)
123
+ if (themeMatch) {
124
+ const themeName = themeMatch[1]
125
+ const preset = themePresets.find((p) => p.id.includes(themeName) || p.name.toLowerCase().includes(themeName))
126
+ if (preset) {
127
+ return { action: 'changeTheme', themeId: preset.id, message: `Switching to the ${preset.name} theme.` }
128
+ }
129
+ }
130
+
131
+ return {
132
+ id: `msg-${Date.now()}`,
133
+ role: 'agent',
134
+ text: "I can help with your site. Try:\n- \"Change the headline to 'New Title'\"\n- \"Add a pricing section\"\n- \"Remove the FAQ\"\n- \"Make the hero a split layout\"\n- \"Switch to midnight theme\"",
135
+ }
136
+ }
137
+
138
+ export function AgentPanel() {
139
+ const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
140
+ const [input, setInput] = useState('')
141
+ const [showTyping, setShowTyping] = useState(false)
142
+ const updateBlockProps = useConfigStore((s) => s.updateBlockProps)
143
+ const addBlock = useConfigStore((s) => s.addBlock)
144
+ const removeBlock = useConfigStore((s) => s.removeBlock)
145
+ const updateBlock = useConfigStore((s) => s.updateBlock)
146
+ const setTheme = useConfigStore((s) => s.setTheme)
147
+ const messagesEndRef = useRef<HTMLDivElement>(null)
148
+
149
+ useEffect(() => {
150
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
151
+ }, [messages, showTyping])
152
+
153
+ function handleApply(msg: ChatMessage) {
154
+ if (!msg.patch?.blockId || !msg.patch?.propKey || !msg.patch?.value) {
155
+ toast.error('Cannot apply: missing patch data')
156
+ return
157
+ }
158
+ updateBlockProps(msg.patch.blockId, { [msg.patch.propKey]: msg.patch.value })
159
+ setMessages((prev) =>
160
+ prev.map((m) => (m.id === msg.id ? { ...m, applied: true } : m))
161
+ )
162
+ toast('Patch applied')
163
+ }
164
+
165
+ function handleReject(msg: ChatMessage) {
166
+ setMessages((prev) =>
167
+ prev.map((m) => (m.id === msg.id ? { ...m, applied: false, patch: undefined } : m))
168
+ )
169
+ toast('Patch rejected')
170
+ }
171
+
172
+ function handleSend() {
173
+ const text = input.trim()
174
+ if (!text) return
175
+
176
+ const userMsg: ChatMessage = { id: `msg-${Date.now()}`, role: 'user', text }
177
+ setMessages((prev) => [...prev, userMsg])
178
+ setInput('')
179
+ setShowTyping(true)
180
+
181
+ // Simulate agent thinking (read fresh blocks inside timeout)
182
+ setTimeout(() => {
183
+ const state = useConfigStore.getState()
184
+ const pages = state.config.pages
185
+ const currentBlocks = pages && pages.length > 0
186
+ ? (pages.find((p) => p.id === state.activePageId) ?? pages[0]).blocks
187
+ : state.config.blocks
188
+ const response = generateResponse(text, currentBlocks)
189
+ setShowTyping(false)
190
+
191
+ // Handle action-based responses
192
+ if ('action' in response) {
193
+ const agentMsg: ChatMessage = { id: `msg-${Date.now()}`, role: 'agent', text: response.message }
194
+ setMessages((prev) => [...prev, agentMsg])
195
+
196
+ if (response.action === 'addBlock') {
197
+ addBlock(response.block)
198
+ toast(`${response.block.type} block added`)
199
+ } else if (response.action === 'removeBlock') {
200
+ removeBlock(response.blockId)
201
+ toast('Block removed')
202
+ } else if (response.action === 'changeVariant') {
203
+ updateBlock(response.blockId, { variant: response.variant })
204
+ toast(`Variant changed to ${response.variant}`)
205
+ } else if (response.action === 'changeTheme') {
206
+ const preset = themePresets.find((p) => p.id === response.themeId)
207
+ if (preset) {
208
+ setTheme(preset.theme)
209
+ toast(`Theme changed to ${preset.name}`)
210
+ }
211
+ }
212
+ } else {
213
+ setMessages((prev) => [...prev, response])
214
+ }
215
+ }, 800 + Math.random() * 600)
216
+ }
217
+
218
+ function handleHint(hint: string) {
219
+ setInput(hint)
220
+ }
221
+
222
+ return (
223
+ <div className="flex flex-col flex-1 overflow-hidden">
224
+ {/* Messages */}
225
+ <div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2.5">
226
+ {messages.map((msg) => (
227
+ <div
228
+ key={msg.id}
229
+ className={`max-w-[94%] px-3 py-2.5 rounded-xl text-[12.5px] leading-relaxed ${
230
+ msg.role === 'user'
231
+ ? 'self-end bg-bg-3 text-text-0 rounded-br-sm'
232
+ : 'self-start bg-green-glow text-text-0 rounded-bl-sm border border-green/10'
233
+ }`}
234
+ >
235
+ {msg.text}
236
+
237
+ {/* JSON patch diff */}
238
+ {msg.patch && (
239
+ <div className="bg-bg-2 border border-border-default rounded-md p-2 mt-2 font-mono text-[10.5px] leading-relaxed">
240
+ <div className="flex items-center justify-between mb-1">
241
+ <span className="font-sans text-[9px] font-semibold uppercase tracking-wider text-text-3">
242
+ JSON Patch
243
+ </span>
244
+ {!msg.applied && (
245
+ <div className="flex gap-1">
246
+ <button
247
+ onClick={() => handleApply(msg)}
248
+ className="px-1.5 py-0.5 rounded text-[9px] bg-green/20 text-green hover:bg-green/30 transition-colors flex items-center gap-0.5"
249
+ >
250
+ <Check size={9} /> Apply
251
+ </button>
252
+ <button
253
+ onClick={() => handleReject(msg)}
254
+ className="px-1.5 py-0.5 rounded text-[9px] bg-status-red/10 text-status-red hover:bg-status-red/20 transition-colors flex items-center gap-0.5"
255
+ >
256
+ <X size={9} /> Reject
257
+ </button>
258
+ </div>
259
+ )}
260
+ {msg.applied && (
261
+ <span className="text-[9px] text-green font-medium flex items-center gap-0.5">
262
+ <Check size={9} /> Applied
263
+ </span>
264
+ )}
265
+ </div>
266
+ <div className="text-text-3 text-[10px] mb-1">{msg.patch.path}</div>
267
+ {msg.patch.removed?.map((line, i) => (
268
+ <div key={`r-${i}`} className="text-status-red line-through opacity-60">
269
+ - {line}
270
+ </div>
271
+ ))}
272
+ {msg.patch.added?.map((line, i) => (
273
+ <div key={`a-${i}`} className="text-green">
274
+ + {line}
275
+ </div>
276
+ ))}
277
+ </div>
278
+ )}
279
+ </div>
280
+ ))}
281
+ {showTyping && <TypingIndicator />}
282
+ <div ref={messagesEndRef} />
283
+ </div>
284
+
285
+ {/* Input */}
286
+ <div className="p-2.5 border-t border-border-default">
287
+ <div className="flex gap-1.5">
288
+ <input
289
+ type="text"
290
+ placeholder="Ask the agent..."
291
+ value={input}
292
+ onChange={(e) => setInput(e.target.value)}
293
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSend() }}
294
+ className="flex-1 px-3 py-2 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3"
295
+ />
296
+ <button
297
+ onClick={handleSend}
298
+ className="w-9 h-9 rounded-lg bg-green flex items-center justify-center text-black shrink-0 hover:bg-green-dim transition-colors"
299
+ aria-label="Send message"
300
+ >
301
+ <Send size={14} />
302
+ </button>
303
+ </div>
304
+ <div className="flex gap-1 mt-1.5 flex-wrap">
305
+ {['Change the headline', 'Add a pricing section', 'Make the hero split', 'Switch to midnight theme'].map((hint) => (
306
+ <span
307
+ key={hint}
308
+ onClick={() => handleHint(hint)}
309
+ className="px-2 py-0.5 rounded-full text-[10.5px] text-text-2 border border-border-default bg-bg-2 cursor-pointer hover:border-green hover:text-green hover:bg-green-glow transition-all"
310
+ >
311
+ {hint}
312
+ </span>
313
+ ))}
314
+ </div>
315
+ </div>
316
+ </div>
317
+ )
318
+ }