@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,344 @@
1
+ import { create } from 'zustand'
2
+ import { persist } from 'zustand/middleware'
3
+ import { produce } from 'immer'
4
+ import type { BlockConfig, SiteConfig, ThemeConfig, PageConfig } from '@/blocks/types'
5
+
6
+ function ensurePages(config: SiteConfig): PageConfig[] {
7
+ if (config.pages && config.pages.length > 0) return config.pages
8
+ return [{ id: 'page-home', name: 'Home', path: '/', blocks: config.blocks }]
9
+ }
10
+
11
+ function getPageBlocks(config: SiteConfig, pageId: string): BlockConfig[] {
12
+ const pages = ensurePages(config)
13
+ const page = pages.find((p) => p.id === pageId)
14
+ return page?.blocks ?? pages[0]?.blocks ?? []
15
+ }
16
+
17
+ interface UndoEntry {
18
+ pages?: PageConfig[]
19
+ blocks: BlockConfig[]
20
+ theme?: Partial<ThemeConfig>
21
+ label: string
22
+ timestamp: number
23
+ }
24
+
25
+ interface ConfigState {
26
+ config: SiteConfig
27
+ activePageId: string
28
+ undoStack: UndoEntry[]
29
+ redoStack: UndoEntry[]
30
+ setConfig: (config: SiteConfig) => void
31
+ setActivePage: (id: string) => void
32
+ getActivePageBlocks: () => BlockConfig[]
33
+ updateBlock: (id: string, updates: Partial<BlockConfig>) => void
34
+ updateBlockProps: (id: string, props: Record<string, unknown>) => void
35
+ addBlock: (block: BlockConfig, index?: number) => void
36
+ removeBlock: (id: string) => void
37
+ duplicateBlock: (id: string) => void
38
+ moveBlock: (fromIndex: number, toIndex: number) => void
39
+ addPage: (name: string, path: string) => string
40
+ removePage: (id: string) => void
41
+ renamePage: (id: string, name: string) => void
42
+ setTheme: (theme: Partial<ThemeConfig>) => void
43
+ updateTheme: (partial: Partial<ThemeConfig>) => void
44
+ previewTheme: (partial: Partial<ThemeConfig>) => void
45
+ undo: () => void
46
+ redo: () => void
47
+ canUndo: () => boolean
48
+ canRedo: () => boolean
49
+ }
50
+
51
+ const defaultBlocks: BlockConfig[] = [
52
+ {
53
+ id: 'block-navbar',
54
+ type: 'navbar',
55
+ variant: 'default',
56
+ props: {
57
+ logo: 'Acme Inc',
58
+ links: ['Features', 'Pricing', 'About', 'Contact'],
59
+ ctaText: 'Get Started',
60
+ },
61
+ },
62
+ {
63
+ id: 'block-hero',
64
+ type: 'hero',
65
+ variant: 'centered',
66
+ props: {
67
+ badge: 'Now in Beta',
68
+ headline: 'Build websites with JSON',
69
+ subheadline: 'The visual editor that agents and humans both understand. Structured config, beautiful output.',
70
+ primaryCta: 'Start Building',
71
+ secondaryCta: 'View Demo',
72
+ },
73
+ },
74
+ {
75
+ id: 'block-features',
76
+ type: 'features',
77
+ variant: 'grid',
78
+ props: {
79
+ label: 'Features',
80
+ title: 'Everything you need',
81
+ subtitle: 'Powerful building blocks for your next website',
82
+ items: [
83
+ { icon: 'Blocks', title: 'Visual Editor', description: 'Drag and drop blocks to build your layout' },
84
+ { icon: 'Code', title: 'JSON Config', description: 'Every change is a clean JSON mutation' },
85
+ { icon: 'Bot', title: 'Agent Ready', description: 'AI agents can read and write your config' },
86
+ ],
87
+ },
88
+ },
89
+ {
90
+ id: 'block-cta',
91
+ type: 'cta',
92
+ variant: 'simple',
93
+ props: {
94
+ headline: 'Ready to get started?',
95
+ subheadline: 'Create your first site in minutes.',
96
+ buttonText: 'Start Free',
97
+ },
98
+ },
99
+ {
100
+ id: 'block-footer',
101
+ type: 'footer',
102
+ variant: 'simple',
103
+ props: {
104
+ logo: 'OpenPage',
105
+ copyright: '2026 OpenPage. All rights reserved.',
106
+ links: ['Privacy', 'Terms', 'Contact'],
107
+ },
108
+ },
109
+ ]
110
+
111
+ export const defaultConfig: SiteConfig = {
112
+ name: 'My Website',
113
+ pages: [{ id: 'page-home', name: 'Home', path: '/', blocks: defaultBlocks }],
114
+ blocks: defaultBlocks,
115
+ }
116
+
117
+ function snapshot(state: ConfigState): { pages?: PageConfig[]; blocks: BlockConfig[]; theme?: Partial<ThemeConfig> } {
118
+ return {
119
+ pages: state.config.pages ? JSON.parse(JSON.stringify(state.config.pages)) : undefined,
120
+ blocks: JSON.parse(JSON.stringify(state.config.blocks)),
121
+ theme: state.config.theme ? JSON.parse(JSON.stringify(state.config.theme)) : undefined,
122
+ }
123
+ }
124
+
125
+ const MAX_UNDO = 50
126
+
127
+ function pushUndo(state: ConfigState, label: string): Partial<ConfigState> {
128
+ const snap = snapshot(state)
129
+ return {
130
+ undoStack: [...state.undoStack, { ...snap, label, timestamp: Date.now() }].slice(-MAX_UNDO),
131
+ redoStack: [],
132
+ }
133
+ }
134
+
135
+ function withPages(config: SiteConfig): SiteConfig {
136
+ const pages = ensurePages(config)
137
+ return { ...config, pages }
138
+ }
139
+
140
+ function mutateActivePageBlocks(
141
+ config: SiteConfig,
142
+ activePageId: string,
143
+ mutator: (blocks: BlockConfig[]) => BlockConfig[],
144
+ ): SiteConfig {
145
+ const pages = ensurePages(config)
146
+ const newPages = pages.map((p) =>
147
+ p.id === activePageId ? { ...p, blocks: mutator([...p.blocks]) } : p,
148
+ )
149
+ // Keep top-level blocks synced with first page for backward compat
150
+ const activeBlocks = newPages.find((p) => p.id === activePageId)?.blocks ?? []
151
+ return { ...config, pages: newPages, blocks: activeBlocks }
152
+ }
153
+
154
+ export const useConfigStore = create<ConfigState>()(
155
+ persist(
156
+ (set, get) => ({
157
+ config: defaultConfig,
158
+ activePageId: 'page-home',
159
+ undoStack: [],
160
+ redoStack: [],
161
+
162
+ setConfig: (config) => {
163
+ const pages = ensurePages(config)
164
+ set({ config: { ...config, pages }, activePageId: pages[0]?.id ?? 'page-home', undoStack: [], redoStack: [] })
165
+ },
166
+
167
+ setActivePage: (id) => set({ activePageId: id }),
168
+
169
+ getActivePageBlocks: () => {
170
+ const state = get()
171
+ return getPageBlocks(state.config, state.activePageId)
172
+ },
173
+
174
+ updateBlock: (id, updates) =>
175
+ set((state) => ({
176
+ ...pushUndo(state, 'Update block'),
177
+ config: produce(withPages(state.config), (draft) => {
178
+ const page = draft.pages!.find((p) => p.id === state.activePageId)
179
+ if (!page) return
180
+ const block = page.blocks.find((b) => b.id === id)
181
+ if (block) Object.assign(block, updates)
182
+ draft.blocks = page.blocks
183
+ }),
184
+ })),
185
+
186
+ updateBlockProps: (id, props) =>
187
+ set((state) => ({
188
+ ...pushUndo(state, 'Update properties'),
189
+ config: produce(withPages(state.config), (draft) => {
190
+ const page = draft.pages!.find((p) => p.id === state.activePageId)
191
+ if (!page) return
192
+ const block = page.blocks.find((b) => b.id === id)
193
+ if (block) Object.assign(block.props, props)
194
+ draft.blocks = page.blocks
195
+ }),
196
+ })),
197
+
198
+ addBlock: (block, index) =>
199
+ set((state) => ({
200
+ ...pushUndo(state, 'Add block'),
201
+ config: mutateActivePageBlocks(withPages(state.config), state.activePageId, (blocks) => {
202
+ if (index !== undefined) {
203
+ blocks.splice(index, 0, block)
204
+ } else {
205
+ blocks.push(block)
206
+ }
207
+ return blocks
208
+ }),
209
+ })),
210
+
211
+ removeBlock: (id) =>
212
+ set((state) => ({
213
+ ...pushUndo(state, 'Remove block'),
214
+ config: mutateActivePageBlocks(withPages(state.config), state.activePageId, (blocks) =>
215
+ blocks.filter((b) => b.id !== id),
216
+ ),
217
+ })),
218
+
219
+ duplicateBlock: (id) =>
220
+ set((state) => {
221
+ const blocks = getPageBlocks(state.config, state.activePageId)
222
+ const idx = blocks.findIndex((b) => b.id === id)
223
+ if (idx === -1) return state
224
+ const original = blocks[idx]
225
+ const clone: BlockConfig = {
226
+ ...JSON.parse(JSON.stringify(original)),
227
+ id: `block-${Date.now()}`,
228
+ }
229
+ return {
230
+ ...pushUndo(state, 'Duplicate block'),
231
+ config: mutateActivePageBlocks(withPages(state.config), state.activePageId, (b) => {
232
+ b.splice(idx + 1, 0, clone)
233
+ return b
234
+ }),
235
+ }
236
+ }),
237
+
238
+ moveBlock: (fromIndex, toIndex) =>
239
+ set((state) => ({
240
+ ...pushUndo(state, 'Move block'),
241
+ config: mutateActivePageBlocks(withPages(state.config), state.activePageId, (blocks) => {
242
+ const [moved] = blocks.splice(fromIndex, 1)
243
+ blocks.splice(toIndex, 0, moved)
244
+ return blocks
245
+ }),
246
+ })),
247
+
248
+ addPage: (name, path) => {
249
+ const id = `page-${Date.now()}`
250
+ set((state) => ({
251
+ ...pushUndo(state, 'Add page'),
252
+ config: produce(withPages(state.config), (draft) => {
253
+ draft.pages!.push({ id, name, path, blocks: [] })
254
+ }),
255
+ activePageId: id,
256
+ }))
257
+ return id
258
+ },
259
+
260
+ removePage: (id) =>
261
+ set((state) => {
262
+ const pages = ensurePages(state.config)
263
+ if (pages.length <= 1) return state
264
+ const newPages = pages.filter((p) => p.id !== id)
265
+ const newActiveId = state.activePageId === id ? newPages[0].id : state.activePageId
266
+ return {
267
+ ...pushUndo(state, 'Remove page'),
268
+ config: { ...state.config, pages: newPages, blocks: newPages[0].blocks },
269
+ activePageId: newActiveId,
270
+ }
271
+ }),
272
+
273
+ renamePage: (id, name) =>
274
+ set((state) => ({
275
+ config: produce(withPages(state.config), (draft) => {
276
+ const page = draft.pages!.find((p) => p.id === id)
277
+ if (page) page.name = name
278
+ }),
279
+ })),
280
+
281
+ setTheme: (theme) =>
282
+ set((state) => ({
283
+ ...pushUndo(state, 'Change theme'),
284
+ config: { ...state.config, theme },
285
+ })),
286
+
287
+ updateTheme: (partial) =>
288
+ set((state) => ({
289
+ ...pushUndo(state, 'Update theme'),
290
+ config: { ...state.config, theme: { ...state.config.theme, ...partial } },
291
+ })),
292
+
293
+ previewTheme: (partial) =>
294
+ set((state) => ({
295
+ config: { ...state.config, theme: { ...state.config.theme, ...partial } },
296
+ })),
297
+
298
+ undo: () =>
299
+ set((state) => {
300
+ if (state.undoStack.length === 0) return state
301
+ const prev = state.undoStack[state.undoStack.length - 1]
302
+ const snap = snapshot(state)
303
+ return {
304
+ undoStack: state.undoStack.slice(0, -1),
305
+ redoStack: [...state.redoStack, { ...snap, label: prev.label, timestamp: Date.now() }],
306
+ config: { ...state.config, pages: prev.pages, blocks: prev.blocks, theme: prev.theme },
307
+ }
308
+ }),
309
+
310
+ redo: () =>
311
+ set((state) => {
312
+ if (state.redoStack.length === 0) return state
313
+ const next = state.redoStack[state.redoStack.length - 1]
314
+ const snap = snapshot(state)
315
+ return {
316
+ redoStack: state.redoStack.slice(0, -1),
317
+ undoStack: [...state.undoStack, { ...snap, label: next.label, timestamp: Date.now() }],
318
+ config: { ...state.config, pages: next.pages, blocks: next.blocks, theme: next.theme },
319
+ }
320
+ }),
321
+
322
+ canUndo: () => get().undoStack.length > 0,
323
+ canRedo: () => get().redoStack.length > 0,
324
+ }),
325
+ {
326
+ name: 'openpage-config',
327
+ version: 2,
328
+ partialize: (state) => ({ config: state.config, activePageId: state.activePageId }),
329
+ migrate: (persisted, version) => {
330
+ const data = persisted as Record<string, unknown>
331
+ if (version === 0 || version === 1 || version === undefined) {
332
+ // v0/v1 -> v2: wrap blocks[] into pages[]
333
+ const config = data.config as SiteConfig | undefined
334
+ if (config && !config.pages) {
335
+ config.pages = [{ id: 'page-home', name: 'Home', path: '/', blocks: config.blocks || [] }]
336
+ }
337
+ data.activePageId = 'page-home'
338
+ return data
339
+ }
340
+ return data
341
+ },
342
+ }
343
+ )
344
+ )
@@ -0,0 +1,50 @@
1
+ import { create } from 'zustand'
2
+
3
+ export type Viewport = 'desktop' | 'tablet' | 'mobile'
4
+
5
+ interface EditorState {
6
+ selectedBlockId: string | null
7
+ viewport: Viewport
8
+ jsonDrawerOpen: boolean
9
+ historyOpen: boolean
10
+ shortcutsModalOpen: boolean
11
+ previewMode: boolean
12
+ activeProjectId: string | null
13
+ // Generation state
14
+ isGenerating: boolean
15
+ generationPrompt: string | null
16
+ generationError: string | null
17
+ selectBlock: (id: string | null) => void
18
+ setViewport: (vp: Viewport) => void
19
+ toggleJsonDrawer: () => void
20
+ toggleHistory: () => void
21
+ toggleShortcutsModal: () => void
22
+ togglePreview: () => void
23
+ setActiveProject: (id: string | null) => void
24
+ setGenerating: (prompt: string | null) => void
25
+ setGenerationError: (err: string | null) => void
26
+ clearGeneration: () => void
27
+ }
28
+
29
+ export const useEditorStore = create<EditorState>()((set) => ({
30
+ selectedBlockId: null,
31
+ viewport: 'desktop',
32
+ jsonDrawerOpen: false,
33
+ historyOpen: false,
34
+ shortcutsModalOpen: false,
35
+ previewMode: false,
36
+ activeProjectId: null,
37
+ isGenerating: false,
38
+ generationPrompt: null,
39
+ generationError: null,
40
+ selectBlock: (id) => set({ selectedBlockId: id }),
41
+ setViewport: (vp) => set({ viewport: vp }),
42
+ toggleJsonDrawer: () => set((s) => ({ jsonDrawerOpen: !s.jsonDrawerOpen })),
43
+ toggleHistory: () => set((s) => ({ historyOpen: !s.historyOpen })),
44
+ toggleShortcutsModal: () => set((s) => ({ shortcutsModalOpen: !s.shortcutsModalOpen })),
45
+ togglePreview: () => set((s) => ({ previewMode: !s.previewMode, ...(!s.previewMode ? { selectedBlockId: null } : {}) })),
46
+ setActiveProject: (id) => set({ activeProjectId: id }),
47
+ setGenerating: (prompt) => set({ isGenerating: !!prompt, generationPrompt: prompt, generationError: null }),
48
+ setGenerationError: (err) => set({ generationError: err }),
49
+ clearGeneration: () => set({ isGenerating: false, generationPrompt: null }),
50
+ }))
package/src/styles.css ADDED
@@ -0,0 +1,210 @@
1
+ /*
2
+ * @siteable/core — Tailwind v4 source stylesheet (raw, unbuilt).
3
+ *
4
+ * Ships the engine-side design tokens, animations, and utilities. Consumers MUST
5
+ * `@import "..."` this file from their own global stylesheet (after their own
6
+ * `@import "tailwindcss"`) and add `@source` entries pointing at the package's
7
+ * src/ directory so Tailwind v4's scanner picks up the block component classes.
8
+ *
9
+ * EXCLUDED (page-level resets, app-side concerns): `@import "tailwindcss"`,
10
+ * `:root { --color-accent-rgb }`, `html { font-size: 14px }`, body/scrollbar/
11
+ * focus-visible resets — these belong to the host app's globals.
12
+ *
13
+ * IMPORTANT: accent-rgb fallbacks (`var(--color-accent-rgb, 34, 197, 94)`)
14
+ * retained verbatim — host apps that omit the `:root` override still get the
15
+ * default Siteable green via the fallback channel.
16
+ */
17
+
18
+ @theme {
19
+ /* Background layers */
20
+ --color-bg-0: #09090b;
21
+ --color-bg-1: #0f0f12;
22
+ --color-bg-2: #18181b;
23
+ --color-bg-3: #1e1e23;
24
+ --color-bg-4: #27272a;
25
+ --color-bg-5: #303036;
26
+
27
+ /* Borders */
28
+ --color-border-default: #2a2a2f;
29
+ --color-border-subtle: #1f1f24;
30
+ --color-border-hover: #3a3a3f;
31
+
32
+ /* Text */
33
+ --color-text-0: #fafafa;
34
+ --color-text-1: #a1a1aa;
35
+ --color-text-2: #71717a;
36
+ --color-text-3: #52525b;
37
+
38
+ /* Accent */
39
+ --color-green: #22c55e;
40
+ --color-green-dim: #16a34a;
41
+ --color-green-glow: rgba(34, 197, 94, 0.12);
42
+ --color-green-glow2: rgba(34, 197, 94, 0.06);
43
+
44
+ /* Status */
45
+ --color-status-red: #ef4444;
46
+ --color-status-yellow: #eab308;
47
+ --color-status-blue: #3b82f6;
48
+
49
+ /* Radius */
50
+ --radius-default: 8px;
51
+ --radius-lg: 12px;
52
+
53
+ /* Fonts */
54
+ --font-sans: "DM Sans", -apple-system, system-ui, sans-serif;
55
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
56
+ --font-display: "Space Grotesk", system-ui, sans-serif;
57
+ }
58
+
59
+ /* Page-level stagger reveal */
60
+ @keyframes fadeInUp {
61
+ from {
62
+ opacity: 0;
63
+ transform: translateY(12px);
64
+ }
65
+ to {
66
+ opacity: 1;
67
+ transform: translateY(0);
68
+ }
69
+ }
70
+
71
+ @keyframes fadeIn {
72
+ from { opacity: 0; }
73
+ to { opacity: 1; }
74
+ }
75
+
76
+ @keyframes scaleIn {
77
+ from {
78
+ opacity: 0;
79
+ transform: scale(0.96);
80
+ }
81
+ to {
82
+ opacity: 1;
83
+ transform: scale(1);
84
+ }
85
+ }
86
+
87
+ @keyframes shimmer {
88
+ 0% { background-position: -200% 0; }
89
+ 100% { background-position: 200% 0; }
90
+ }
91
+
92
+ @keyframes selectPulse {
93
+ 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.3); }
94
+ 50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
95
+ 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
96
+ }
97
+
98
+ @utility animate-fade-in-up {
99
+ animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
100
+ }
101
+
102
+ @utility animate-fade-in {
103
+ animation: fadeIn 0.3s ease-out both;
104
+ }
105
+
106
+ @utility animate-scale-in {
107
+ animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
108
+ }
109
+
110
+ @utility animate-shimmer {
111
+ animation: shimmer 1.5s ease-in-out infinite;
112
+ }
113
+
114
+ @utility animate-select-pulse {
115
+ animation: selectPulse 0.4s ease-out;
116
+ }
117
+
118
+ @utility stagger-1 { animation-delay: 0ms; }
119
+ @utility stagger-2 { animation-delay: 50ms; }
120
+ @utility stagger-3 { animation-delay: 100ms; }
121
+ @utility stagger-4 { animation-delay: 150ms; }
122
+ @utility stagger-5 { animation-delay: 200ms; }
123
+ @utility stagger-6 { animation-delay: 250ms; }
124
+ @utility stagger-7 { animation-delay: 300ms; }
125
+ @utility stagger-8 { animation-delay: 350ms; }
126
+
127
+ /* Card hover lift */
128
+ @utility card-lift {
129
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s;
130
+ }
131
+
132
+ @utility card-lift-hover {
133
+ transform: translateY(-4px);
134
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35), 0 4px 10px rgba(0, 0, 0, 0.2), 0 0 16px rgba(34, 197, 94, 0.06);
135
+ border-color: var(--color-border-hover);
136
+ }
137
+
138
+ /* Accent glow shadows - @utility so Tailwind v4 generates hover:/group-hover: variants */
139
+ @utility accent-glow-sm {
140
+ box-shadow: 0 0 16px rgba(var(--color-accent-rgb, 34, 197, 94), 0.1);
141
+ }
142
+ @utility accent-glow-md {
143
+ box-shadow: 0 0 16px rgba(var(--color-accent-rgb, 34, 197, 94), 0.15);
144
+ }
145
+ @utility accent-glow-lg {
146
+ box-shadow: 0 0 20px rgba(var(--color-accent-rgb, 34, 197, 94), 0.25);
147
+ }
148
+ @utility accent-glow-xl {
149
+ box-shadow: 0 0 24px rgba(var(--color-accent-rgb, 34, 197, 94), 0.3);
150
+ }
151
+ @utility accent-glow-ring {
152
+ box-shadow: 0 0 32px rgba(var(--color-accent-rgb, 34, 197, 94), 0.1);
153
+ }
154
+
155
+ /* Scroll-reveal animation system
156
+ Parent gets .scroll-revealed when visible in viewport.
157
+ Children with .reveal-* classes animate in with staggered delays. */
158
+
159
+ .reveal-fade-up,
160
+ .reveal-scale,
161
+ .reveal-slide-left,
162
+ .reveal-slide-right {
163
+ opacity: 0;
164
+ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
165
+ transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
166
+ transition-delay: var(--reveal-delay, 0ms);
167
+ }
168
+
169
+ .reveal-fade-up { transform: translateY(16px); }
170
+ .reveal-scale { transform: scale(0.95); }
171
+ .reveal-slide-left { transform: translateX(-20px); }
172
+ .reveal-slide-right { transform: translateX(20px); }
173
+
174
+ .scroll-revealed .reveal-fade-up,
175
+ .scroll-revealed .reveal-scale,
176
+ .scroll-revealed .reveal-slide-left,
177
+ .scroll-revealed .reveal-slide-right {
178
+ opacity: 1;
179
+ transform: none;
180
+ }
181
+
182
+ /* Stagger delay utilities */
183
+ @utility reveal-d1 { --reveal-delay: 0ms; }
184
+ @utility reveal-d2 { --reveal-delay: 80ms; }
185
+ @utility reveal-d3 { --reveal-delay: 160ms; }
186
+ @utility reveal-d4 { --reveal-delay: 240ms; }
187
+ @utility reveal-d5 { --reveal-delay: 320ms; }
188
+ @utility reveal-d6 { --reveal-delay: 400ms; }
189
+ @utility reveal-d7 { --reveal-delay: 480ms; }
190
+ @utility reveal-d8 { --reveal-delay: 560ms; }
191
+
192
+ /* Skip to content link - visible only on focus */
193
+ .skip-to-content {
194
+ position: fixed;
195
+ top: -100%;
196
+ left: 50%;
197
+ transform: translateX(-50%);
198
+ z-index: 200;
199
+ padding: 8px 16px;
200
+ background: var(--color-green);
201
+ color: #000;
202
+ font-size: 13px;
203
+ font-weight: 600;
204
+ border-radius: 0 0 8px 8px;
205
+ transition: top 0.2s;
206
+ }
207
+ .skip-to-content:focus {
208
+ top: 0;
209
+ outline: none;
210
+ }