prev-cli 0.24.19 → 0.25.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 (150) hide show
  1. package/dist/cli.js +2006 -1703
  2. package/dist/previews/components/cart-item/index.d.ts +5 -0
  3. package/dist/previews/components/price-tag/index.d.ts +6 -0
  4. package/dist/previews/screens/cart/empty.d.ts +1 -0
  5. package/dist/previews/screens/cart/index.d.ts +1 -0
  6. package/dist/previews/screens/payment/error.d.ts +1 -0
  7. package/dist/previews/screens/payment/index.d.ts +1 -0
  8. package/dist/previews/screens/payment/processing.d.ts +1 -0
  9. package/dist/previews/screens/receipt/index.d.ts +1 -0
  10. package/dist/previews/shared/data.d.ts +30 -0
  11. package/dist/src/content/config-parser.d.ts +30 -0
  12. package/dist/src/content/flow-verifier.d.ts +21 -0
  13. package/dist/src/content/preview-types.d.ts +288 -0
  14. package/dist/{vite → src/content}/previews.d.ts +3 -11
  15. package/dist/{preview-runtime → src/preview-runtime}/build-optimized.d.ts +2 -0
  16. package/dist/{preview-runtime → src/preview-runtime}/build.d.ts +1 -1
  17. package/dist/src/preview-runtime/region-bridge.d.ts +1 -0
  18. package/dist/{preview-runtime → src/preview-runtime}/types.d.ts +18 -0
  19. package/dist/src/preview-runtime/vendors.d.ts +11 -0
  20. package/dist/{renderers → src/renderers}/index.d.ts +1 -1
  21. package/dist/{renderers → src/renderers}/types.d.ts +3 -31
  22. package/dist/src/server/build.d.ts +6 -0
  23. package/dist/src/server/dev.d.ts +13 -0
  24. package/dist/src/server/plugins/aliases.d.ts +5 -0
  25. package/dist/src/server/plugins/mdx.d.ts +5 -0
  26. package/dist/src/server/plugins/virtual-modules.d.ts +8 -0
  27. package/dist/src/server/preview.d.ts +10 -0
  28. package/dist/src/server/routes/component-bundle.d.ts +1 -0
  29. package/dist/src/server/routes/jsx-bundle.d.ts +3 -0
  30. package/dist/src/server/routes/og-image.d.ts +15 -0
  31. package/dist/src/server/routes/preview-bundle.d.ts +1 -0
  32. package/dist/src/server/routes/preview-config.d.ts +1 -0
  33. package/dist/src/server/routes/tokens.d.ts +1 -0
  34. package/dist/{vite → src/server}/start.d.ts +5 -2
  35. package/dist/{ui → src/ui}/button.d.ts +1 -1
  36. package/dist/{validators → src/validators}/index.d.ts +0 -5
  37. package/dist/{validators → src/validators}/semantic-validator.d.ts +2 -3
  38. package/package.json +8 -11
  39. package/src/jsx/CLAUDE.md +18 -0
  40. package/src/jsx/jsx-runtime.ts +1 -1
  41. package/src/preview-runtime/CLAUDE.md +21 -0
  42. package/src/preview-runtime/build-optimized.ts +189 -73
  43. package/src/preview-runtime/build.ts +75 -79
  44. package/src/preview-runtime/fast-template.html +5 -1
  45. package/src/preview-runtime/region-bridge.test.ts +41 -0
  46. package/src/preview-runtime/region-bridge.ts +101 -0
  47. package/src/preview-runtime/types.ts +6 -0
  48. package/src/preview-runtime/vendors.ts +215 -22
  49. package/src/primitives/CLAUDE.md +17 -0
  50. package/src/theme/CLAUDE.md +20 -0
  51. package/src/theme/Preview.tsx +10 -4
  52. package/src/theme/Toolbar.tsx +2 -2
  53. package/src/theme/entry.tsx +247 -121
  54. package/src/theme/hooks/useAnnotations.ts +77 -0
  55. package/src/theme/hooks/useApprovalStatus.ts +50 -0
  56. package/src/theme/hooks/useSnapshots.ts +147 -0
  57. package/src/theme/hooks/useStorage.ts +26 -0
  58. package/src/theme/hooks/useTokenOverrides.ts +56 -0
  59. package/src/theme/hooks/useViewport.ts +23 -0
  60. package/src/theme/icons.tsx +39 -1
  61. package/src/theme/index.html +18 -0
  62. package/src/theme/mdx-components.tsx +1 -1
  63. package/src/theme/previews/AnnotationLayer.tsx +285 -0
  64. package/src/theme/previews/AnnotationPin.tsx +61 -0
  65. package/src/theme/previews/AnnotationThread.tsx +257 -0
  66. package/src/theme/previews/CLAUDE.md +18 -0
  67. package/src/theme/previews/ComponentPreview.tsx +487 -107
  68. package/src/theme/previews/FlowDiagram.tsx +111 -0
  69. package/src/theme/previews/FlowPreview.tsx +938 -174
  70. package/src/theme/previews/PreviewRouter.tsx +1 -4
  71. package/src/theme/previews/ScreenPreview.tsx +515 -175
  72. package/src/theme/previews/SnapshotButton.tsx +68 -0
  73. package/src/theme/previews/SnapshotCompare.tsx +216 -0
  74. package/src/theme/previews/SnapshotPanel.tsx +274 -0
  75. package/src/theme/previews/StatusBadge.tsx +66 -0
  76. package/src/theme/previews/StatusDropdown.tsx +158 -0
  77. package/src/theme/previews/TokenPlayground.tsx +438 -0
  78. package/src/theme/previews/ViewportControls.tsx +67 -0
  79. package/src/theme/previews/flow-diagram.test.ts +141 -0
  80. package/src/theme/previews/flow-diagram.ts +109 -0
  81. package/src/theme/previews/flow-navigation.test.ts +90 -0
  82. package/src/theme/previews/flow-navigation.ts +47 -0
  83. package/src/theme/previews/machines/derived.test.ts +225 -0
  84. package/src/theme/previews/machines/derived.ts +73 -0
  85. package/src/theme/previews/machines/flow-machine.test.ts +379 -0
  86. package/src/theme/previews/machines/flow-machine.ts +207 -0
  87. package/src/theme/previews/machines/screen-machine.test.ts +149 -0
  88. package/src/theme/previews/machines/screen-machine.ts +76 -0
  89. package/src/theme/previews/stores/flow-store.test.ts +157 -0
  90. package/src/theme/previews/stores/flow-store.ts +49 -0
  91. package/src/theme/previews/stores/screen-store.test.ts +68 -0
  92. package/src/theme/previews/stores/screen-store.ts +33 -0
  93. package/src/theme/storage.test.ts +97 -0
  94. package/src/theme/storage.ts +71 -0
  95. package/src/theme/styles.css +296 -25
  96. package/src/theme/types.ts +64 -0
  97. package/src/tokens/CLAUDE.md +16 -0
  98. package/src/tokens/resolver.ts +1 -1
  99. package/dist/preview-runtime/vendors.d.ts +0 -6
  100. package/dist/vite/config-parser.d.ts +0 -13
  101. package/dist/vite/config.d.ts +0 -12
  102. package/dist/vite/plugins/config-plugin.d.ts +0 -3
  103. package/dist/vite/plugins/debug-plugin.d.ts +0 -3
  104. package/dist/vite/plugins/entry-plugin.d.ts +0 -2
  105. package/dist/vite/plugins/fumadocs-plugin.d.ts +0 -9
  106. package/dist/vite/plugins/pages-plugin.d.ts +0 -5
  107. package/dist/vite/plugins/previews-plugin.d.ts +0 -2
  108. package/dist/vite/plugins/tokens-plugin.d.ts +0 -2
  109. package/dist/vite/preview-types.d.ts +0 -70
  110. package/src/theme/previews/AtlasPreview.tsx +0 -528
  111. package/dist/{cli.d.ts → src/cli.d.ts} +0 -0
  112. package/dist/{config → src/config}/index.d.ts +0 -0
  113. package/dist/{config → src/config}/loader.d.ts +0 -0
  114. package/dist/{config → src/config}/schema.d.ts +0 -0
  115. package/dist/{vite → src/content}/pages.d.ts +0 -0
  116. package/dist/{jsx → src/jsx}/adapters/html.d.ts +0 -0
  117. package/dist/{jsx → src/jsx}/adapters/react.d.ts +0 -0
  118. package/dist/{jsx → src/jsx}/define-component.d.ts +0 -0
  119. package/dist/{jsx → src/jsx}/index.d.ts +0 -0
  120. package/dist/{jsx → src/jsx}/jsx-runtime.d.ts +0 -0
  121. package/dist/{jsx → src/jsx}/migrate.d.ts +0 -0
  122. package/dist/{jsx → src/jsx}/schemas/index.d.ts +0 -0
  123. package/dist/{jsx → src/jsx}/schemas/primitives.d.ts +10 -10
  124. package/dist/{jsx → src/jsx}/schemas/tokens.d.ts +3 -3
  125. /package/dist/{jsx → src/jsx}/validation.d.ts +0 -0
  126. /package/dist/{jsx → src/jsx}/vnode.d.ts +0 -0
  127. /package/dist/{migrate.d.ts → src/migrate.d.ts} +0 -0
  128. /package/dist/{preview-runtime → src/preview-runtime}/tailwind.d.ts +0 -0
  129. /package/dist/{primitives → src/primitives}/index.d.ts +0 -0
  130. /package/dist/{primitives → src/primitives}/migrate.d.ts +0 -0
  131. /package/dist/{primitives → src/primitives}/parser.d.ts +0 -0
  132. /package/dist/{primitives → src/primitives}/template-parser.d.ts +0 -0
  133. /package/dist/{primitives → src/primitives}/template-renderer.d.ts +0 -0
  134. /package/dist/{primitives → src/primitives}/types.d.ts +0 -0
  135. /package/dist/{renderers → src/renderers}/html/index.d.ts +0 -0
  136. /package/dist/{renderers → src/renderers}/react/index.d.ts +0 -0
  137. /package/dist/{renderers → src/renderers}/registry.d.ts +0 -0
  138. /package/dist/{renderers → src/renderers}/render.d.ts +0 -0
  139. /package/dist/{tokens → src/tokens}/defaults.d.ts +0 -0
  140. /package/dist/{tokens → src/tokens}/resolver.d.ts +0 -0
  141. /package/dist/{tokens → src/tokens}/utils.d.ts +0 -0
  142. /package/dist/{tokens → src/tokens}/validation.d.ts +0 -0
  143. /package/dist/{typecheck → src/typecheck}/index.d.ts +0 -0
  144. /package/dist/{ui → src/ui}/card.d.ts +0 -0
  145. /package/dist/{ui → src/ui}/index.d.ts +0 -0
  146. /package/dist/{ui → src/ui}/utils.d.ts +0 -0
  147. /package/dist/{utils → src/utils}/cache.d.ts +0 -0
  148. /package/dist/{utils → src/utils}/debug.d.ts +0 -0
  149. /package/dist/{utils → src/utils}/port.d.ts +0 -0
  150. /package/dist/{validators → src/validators}/schema-validator.d.ts +0 -0
@@ -10,6 +10,7 @@ interface PreviewProps {
10
10
  title?: string
11
11
  mode?: 'wasm' | 'legacy'
12
12
  showHeader?: boolean // Show full header with back button and devtools
13
+ state?: string | null // Optional state name for screen previews
13
14
  }
14
15
 
15
16
  type DeviceMode = 'mobile' | 'tablet' | 'desktop'
@@ -20,7 +21,7 @@ const DEVICE_WIDTHS: Record<DeviceMode, number | '100%'> = {
20
21
  desktop: '100%',
21
22
  }
22
23
 
23
- export function Preview({ src, height = 400, title, mode = 'wasm', showHeader = false }: PreviewProps) {
24
+ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader = false, state = null }: PreviewProps) {
24
25
  const [isFullscreen, setIsFullscreen] = useState(false)
25
26
  // Default to 'mobile' device mode on mobile viewports to match user's actual environment
26
27
  const [deviceMode, setDeviceMode] = useState<DeviceMode>(() => {
@@ -42,14 +43,19 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
42
43
 
43
44
  // In production, always use pre-built static previews
44
45
  // In dev, use WASM runtime for live bundling
45
- const isDev = import.meta.env?.DEV ?? false
46
+ const isDev = import.meta.env.DEV ?? false
46
47
  const effectiveMode = isDev ? mode : 'legacy'
47
48
 
48
49
  // Get base URL for proper subpath deployment support
49
- const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
50
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '')
50
51
 
51
52
  // URL depends on mode - wasm mode needs src param, legacy uses pre-built files
52
- const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `${baseUrl}/_preview/${src}/`
53
+ // Include state parameter if provided (for screen previews with multiple states)
54
+ const stateParam = state ? `&state=${state}` : ''
55
+ const stateUrlPart = state ? `?state=${state}` : ''
56
+ const previewUrl = effectiveMode === 'wasm'
57
+ ? `/_preview-runtime?src=${src}${stateParam}`
58
+ : `${baseUrl}/_preview/${src}/${stateUrlPart}`
53
59
  const displayTitle = title || src
54
60
 
55
61
  // Calculate current width
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useRef, useEffect } from 'react'
2
2
  import { Link, useLocation } from '@tanstack/react-router'
3
3
  import type { PageTree } from 'fumadocs-core/server'
4
- import { previews } from 'virtual:prev-previews'
4
+ import { previewUnits } from 'virtual:prev-previews'
5
5
  import { Icon } from './icons'
6
6
  import { useDevTools } from './DevToolsContext'
7
7
  import './Toolbar.css'
@@ -76,7 +76,7 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
76
76
  <Icon name="menu" size={18} />
77
77
  </button>
78
78
 
79
- {previews && previews.length > 0 && (
79
+ {previewUnits && previewUnits.length > 0 && (
80
80
  <Link to="/previews" className={`toolbar-btn ${isOnPreviews ? 'active' : ''}`} title="Previews">
81
81
  <Icon name="grid" size={18} />
82
82
  </Link>
@@ -15,8 +15,8 @@ import {
15
15
  import { MDXProvider } from '@mdx-js/react'
16
16
  import { pages, sidebar } from 'virtual:prev-pages'
17
17
  import { pageModules } from 'virtual:prev-page-modules'
18
- import { previews, previewUnits } from 'virtual:prev-previews'
19
- import type { PreviewUnit, PreviewType } from '../vite/preview-types'
18
+ import { previewUnits } from 'virtual:prev-previews'
19
+ import type { PreviewUnit, PreviewType } from '../content/preview-types'
20
20
  import { Preview } from './Preview'
21
21
  import { TokensPage } from './previews/TokensPage'
22
22
  import { useDiagrams } from './diagrams'
@@ -24,6 +24,9 @@ import { Layout } from './Layout'
24
24
  import { MetadataBlock } from './MetadataBlock'
25
25
  import { mdxComponents } from './mdx-components'
26
26
  import { DevToolsProvider } from './DevToolsContext'
27
+ import { StatusBadge } from './previews/StatusBadge'
28
+ import { useApprovalStatus } from './hooks/useApprovalStatus'
29
+ import { SnapshotCompare } from './previews/SnapshotCompare'
27
30
  import './styles.css'
28
31
 
29
32
  // PageTree types (simplified from fumadocs-core)
@@ -110,15 +113,10 @@ const CATEGORY_META: Record<PreviewType, { label: string; icon: string; descript
110
113
  icon: '⇢',
111
114
  description: 'Multi-step user journeys',
112
115
  },
113
- atlas: {
114
- label: 'Atlas',
115
- icon: '◎',
116
- description: 'Information architecture maps',
117
- },
118
116
  }
119
117
 
120
118
  // Category display order
121
- const CATEGORY_ORDER: PreviewType[] = ['component', 'screen', 'flow', 'atlas']
119
+ const CATEGORY_ORDER: PreviewType[] = ['component', 'screen', 'flow']
122
120
 
123
121
  // Group previews by type
124
122
  function groupByType(units: PreviewUnit[]): Map<PreviewType, PreviewUnit[]> {
@@ -130,50 +128,55 @@ function groupByType(units: PreviewUnit[]): Map<PreviewType, PreviewUnit[]> {
130
128
  return grouped
131
129
  }
132
130
 
133
- // Category section component
131
+ // Category section component with carousel navigation
134
132
  function CategorySection({ type, units }: { type: PreviewType; units: PreviewUnit[] }) {
135
133
  const meta = CATEGORY_META[type]
134
+ const scrollRef = React.useRef<HTMLDivElement>(null)
135
+
136
+ const scroll = (direction: 'left' | 'right') => {
137
+ const container = scrollRef.current
138
+ if (!container) return
139
+ const cardWidth = 280 + 16 // card width + gap
140
+ const scrollAmount = direction === 'left' ? -cardWidth : cardWidth
141
+ container.scrollBy({ left: scrollAmount, behavior: 'smooth' })
142
+ }
136
143
 
137
144
  return (
138
- <section style={{ marginBottom: '32px' }}>
139
- <div style={{
140
- display: 'flex',
141
- alignItems: 'center',
142
- gap: '10px',
143
- marginBottom: '16px',
144
- paddingBottom: '12px',
145
- borderBottom: '1px solid var(--fd-border)',
146
- }}>
147
- <span style={{ fontSize: '20px' }}>{meta.icon}</span>
148
- <div>
149
- <h2 style={{
150
- fontSize: '18px',
151
- fontWeight: '600',
152
- margin: 0,
153
- color: 'var(--fd-foreground)',
154
- }}>
145
+ <section className="category-section">
146
+ <div className="category-header">
147
+ <span className="category-icon">{meta.icon}</span>
148
+ <div className="category-info">
149
+ <h2 className="category-title">
155
150
  {meta.label}
156
- <span style={{
157
- marginLeft: '8px',
158
- fontSize: '14px',
159
- fontWeight: '400',
160
- color: 'var(--fd-muted-foreground)',
161
- }}>
162
- ({units.length})
163
- </span>
151
+ <span className="category-count">({units.length})</span>
164
152
  </h2>
165
- <p style={{
166
- fontSize: '13px',
167
- color: 'var(--fd-muted-foreground)',
168
- margin: 0,
169
- }}>
170
- {meta.description}
171
- </p>
153
+ <p className="category-description">{meta.description}</p>
154
+ </div>
155
+ <div className="category-nav">
156
+ <button
157
+ type="button"
158
+ className="category-nav-btn"
159
+ onClick={() => scroll('left')}
160
+ aria-label="Previous"
161
+ >
162
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
163
+ <path d="M15 18l-6-6 6-6" />
164
+ </svg>
165
+ </button>
166
+ <button
167
+ type="button"
168
+ className="category-nav-btn"
169
+ onClick={() => scroll('right')}
170
+ aria-label="Next"
171
+ >
172
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
173
+ <path d="M9 18l6-6-6-6" />
174
+ </svg>
175
+ </button>
172
176
  </div>
173
177
  </div>
174
- <div className="previews-grid">
178
+ <div className="previews-grid" ref={scrollRef}>
175
179
  {units.map((unit) => {
176
- // Extract full path name from route (e.g., "/_preview/components/button" -> "components/button")
177
180
  const fullName = unit.route.replace(/^\/_preview\//, '')
178
181
  return (
179
182
  <PreviewCard
@@ -181,6 +184,7 @@ function CategorySection({ type, units }: { type: PreviewType; units: PreviewUni
181
184
  name={fullName}
182
185
  title={unit.config?.title}
183
186
  status={unit.config?.status}
187
+ type={unit.type}
184
188
  />
185
189
  )
186
190
  })}
@@ -191,21 +195,9 @@ function CategorySection({ type, units }: { type: PreviewType; units: PreviewUni
191
195
 
192
196
  // Previews catalog - Storybook-like gallery with categorized sections
193
197
  function PreviewsCatalog() {
194
- // Use previewUnits for categorization, fall back to legacy previews
195
198
  const units = previewUnits || []
196
- const legacyPreviews = previews || []
197
-
198
- // If no units but have legacy previews, convert them
199
- const allUnits: PreviewUnit[] = units.length > 0 ? units : legacyPreviews.map((p: { name: string; route: string }) => ({
200
- type: 'component' as PreviewType,
201
- name: p.name,
202
- path: '',
203
- route: p.route,
204
- config: null,
205
- files: { index: '' },
206
- }))
207
-
208
- if (allUnits.length === 0) {
199
+
200
+ if (units.length === 0) {
209
201
  return (
210
202
  <div style={{ padding: '40px 20px', textAlign: 'center' }}>
211
203
  <h1 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
@@ -227,19 +219,16 @@ function PreviewsCatalog() {
227
219
  )
228
220
  }
229
221
 
230
- const grouped = groupByType(allUnits)
231
- const totalCount = allUnits.length
222
+ const grouped = groupByType(units)
223
+ const totalCount = units.length
232
224
 
233
225
  return (
234
226
  <div className="previews-catalog">
235
227
  {/* Header */}
236
- <div style={{ marginBottom: '32px' }}>
237
- <h1 style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '8px' }}>
238
- Previews
239
- </h1>
240
- <p style={{ color: 'var(--fd-muted-foreground)', margin: 0 }}>
241
- {totalCount} preview{totalCount !== 1 ? 's' : ''} across {grouped.size} categor{grouped.size !== 1 ? 'ies' : 'y'}.
242
- Click any preview to open it.
228
+ <div className="catalog-header">
229
+ <h1 className="catalog-title">Previews</h1>
230
+ <p className="catalog-subtitle">
231
+ {totalCount} preview{totalCount !== 1 ? 's' : ''} across {grouped.size} categor{grouped.size !== 1 ? 'ies' : 'y'}
243
232
  </p>
244
233
  </div>
245
234
 
@@ -250,19 +239,11 @@ function PreviewsCatalog() {
250
239
  return <CategorySection key={type} type={type} units={units} />
251
240
  })}
252
241
 
253
- {/* Tip */}
254
- <div style={{
255
- marginTop: '32px',
256
- padding: '14px 16px',
257
- backgroundColor: 'var(--fd-muted)',
258
- border: '1px solid var(--fd-border)',
259
- borderRadius: '10px',
260
- }}>
261
- <p style={{ margin: 0, fontSize: '14px', color: 'var(--fd-muted-foreground)' }}>
262
- <strong style={{ color: 'var(--fd-foreground)' }}>Tip:</strong> Embed any preview in your MDX docs with{' '}
263
- <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px', fontFamily: 'var(--fd-font-mono)' }}>
264
- {'<Preview src="name" />'}
265
- </code>
242
+ {/* Tip - hidden on mobile */}
243
+ <div className="catalog-tip">
244
+ <p>
245
+ <strong>Tip:</strong> Embed any preview in your MDX docs with{' '}
246
+ <code>{'<Preview src="name" />'}</code>
266
247
  </p>
267
248
  </div>
268
249
  </div>
@@ -272,38 +253,36 @@ function PreviewsCatalog() {
272
253
  // Individual preview card - clickable thumbnail with WASM preview communication
273
254
  import type { PreviewConfig, PreviewMessage } from '../preview-runtime/types'
274
255
 
275
- function PreviewCard({ name, title, status }: { name: string; title?: string; status?: 'draft' | 'stable' | 'deprecated' }) {
256
+ function PreviewCard({ name, title, status, type }: { name: string; title?: string; status?: 'draft' | 'stable' | 'deprecated'; type?: PreviewType }) {
257
+ const { status: approvalStatus } = useApprovalStatus(name)
258
+ // Config-only types (flow) have no JS entry point — show a styled placeholder instead of iframe
259
+ const isConfigOnly = type === 'flow'
260
+
276
261
  const iframeRef = React.useRef<HTMLIFrameElement>(null)
277
- const [isLoaded, setIsLoaded] = React.useState(false)
262
+ // In production, start as loaded since static files are fast
263
+ const isDev = import.meta.env.DEV ?? false
264
+ const [isLoaded, setIsLoaded] = React.useState(!isDev || isConfigOnly)
278
265
  const [loadError, setLoadError] = React.useState(false)
279
266
 
280
- // In production, use pre-built static files; in dev, use WASM runtime
281
- const isDev = import.meta.env?.DEV ?? false
282
- const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
283
- const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
267
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '')
268
+ const previewUrl = isConfigOnly ? '' : (isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`)
284
269
 
285
- // Timeout for loading - show placeholder if too slow
270
+ // Timeout for loading - only needed in dev mode
286
271
  React.useEffect(() => {
272
+ if (!isDev || isConfigOnly) return
273
+
287
274
  const timeout = setTimeout(() => {
288
275
  if (!isLoaded) {
289
276
  setLoadError(true)
290
277
  }
291
- }, 5000) // 5 second timeout
278
+ }, 5000)
292
279
 
293
280
  return () => clearTimeout(timeout)
294
- }, [isLoaded])
281
+ }, [isLoaded, isDev, isConfigOnly])
295
282
 
296
283
  // Set up WASM preview communication for thumbnail (dev mode only)
297
284
  React.useEffect(() => {
298
- if (!isDev) {
299
- // In production, just mark as loaded when iframe loads
300
- const iframe = iframeRef.current
301
- if (iframe) {
302
- iframe.onload = () => setIsLoaded(true)
303
- iframe.onerror = () => setLoadError(true)
304
- }
305
- return
306
- }
285
+ if (!isDev || isConfigOnly) return
307
286
 
308
287
  const iframe = iframeRef.current
309
288
  if (!iframe) return
@@ -346,35 +325,46 @@ function PreviewCard({ name, title, status }: { name: string; title?: string; st
346
325
  <Link to={`/previews/${name}`} className="preview-card">
347
326
  {/* Thumbnail preview */}
348
327
  <div className="preview-card-thumbnail">
349
- {/* Loading state */}
350
- {!isLoaded && !loadError && (
351
- <div className="preview-card-loading">
352
- <div className="preview-card-spinner" />
353
- </div>
354
- )}
355
- {/* Error/timeout placeholder */}
356
- {loadError && (
357
- <div className="preview-card-placeholder">
358
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
359
- <rect x="3" y="3" width="18" height="18" rx="2" />
360
- <path d="M9 9l6 6m0-6l-6 6" />
361
- </svg>
362
- <span>Preview</span>
328
+ {isConfigOnly ? (
329
+ /* Config-only types: styled placeholder with category icon */
330
+ <div className="preview-card-placeholder" style={{ opacity: 1 }}>
331
+ <span style={{ fontSize: '32px' }}>⇢</span>
332
+ <span>Flow</span>
363
333
  </div>
334
+ ) : (
335
+ <>
336
+ {/* Loading state */}
337
+ {!isLoaded && !loadError && (
338
+ <div className="preview-card-loading">
339
+ <div className="preview-card-spinner" />
340
+ </div>
341
+ )}
342
+ {/* Error/timeout placeholder */}
343
+ {loadError && (
344
+ <div className="preview-card-placeholder">
345
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
346
+ <rect x="3" y="3" width="18" height="18" rx="2" />
347
+ <path d="M9 9l6 6m0-6l-6 6" />
348
+ </svg>
349
+ <span>Preview</span>
350
+ </div>
351
+ )}
352
+ <iframe
353
+ ref={iframeRef}
354
+ src={previewUrl}
355
+ className="preview-card-iframe"
356
+ style={{ opacity: isLoaded && !loadError ? 1 : 0 }}
357
+ title={name}
358
+ loading="lazy"
359
+ />
360
+ </>
364
361
  )}
365
- <iframe
366
- ref={iframeRef}
367
- src={previewUrl}
368
- className="preview-card-iframe"
369
- style={{ opacity: isLoaded && !loadError ? 1 : 0 }}
370
- title={name}
371
- loading="lazy"
372
- />
373
362
  </div>
374
363
  {/* Card footer */}
375
364
  <div className="preview-card-footer">
376
365
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
377
366
  <h3 className="preview-card-title">{title || name}</h3>
367
+ <StatusBadge status={approvalStatus} compact />
378
368
  {status && status !== 'stable' && (
379
369
  <span style={{
380
370
  fontSize: '10px',
@@ -396,18 +386,132 @@ function PreviewCard({ name, title, status }: { name: string; title?: string; st
396
386
  }
397
387
 
398
388
  // Individual preview page - full view with devtools in toolbar
389
+ // Uses specialized component for flow, generic Preview for components/screens
390
+ import { FlowPreview } from './previews/FlowPreview'
391
+
392
+ // Standalone preview embed (for iframe thumbnails in production)
393
+ // Renders just the preview content without Layout wrapper
394
+ function PreviewEmbed() {
395
+ const params = useParams({ strict: false })
396
+ const name = (params as any)['_splat'] || (params as any)['*'] || ''
397
+
398
+ // In production, always use pre-built static files
399
+ const isDev = import.meta.env.DEV ?? false
400
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '')
401
+
402
+ // Parse type from name (e.g., "flows/checkout" -> type="flow", unitName="checkout")
403
+ const match = name.match(/^(components|screens|flows)\/(.+)$/)
404
+ if (!match) {
405
+ // Fallback for legacy preview paths without type prefix
406
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
407
+ return (
408
+ <iframe
409
+ src={previewUrl}
410
+ style={{ width: '100%', height: '100vh', border: 'none' }}
411
+ title={name}
412
+ />
413
+ )
414
+ }
415
+
416
+ const [, typeFolder, unitName] = match
417
+ const type = typeFolder === 'flows' ? 'flow' : typeFolder.slice(0, -1)
418
+
419
+ // Find the preview unit
420
+ const unit = previewUnits.find(u => u.type === type && u.name === unitName)
421
+
422
+ if (!unit) {
423
+ return (
424
+ <div style={{ padding: '32px', textAlign: 'center', color: '#888' }}>
425
+ Preview not found: {name}
426
+ </div>
427
+ )
428
+ }
429
+
430
+ // For flows, render the specialized component
431
+ if (unit.type === 'flow') {
432
+ return <FlowPreview unit={unit} />
433
+ }
434
+
435
+ // For components and screens, use iframe to load static HTML
436
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
437
+ return (
438
+ <iframe
439
+ src={previewUrl}
440
+ style={{ width: '100%', height: '100vh', border: 'none' }}
441
+ title={name}
442
+ />
443
+ )
444
+ }
445
+
399
446
  function PreviewPage() {
400
447
  const params = useParams({ strict: false })
448
+ const location = useLocation()
401
449
  // Splat param captures the full path after /previews/
402
450
  const name = (params as any)['_splat'] || (params as any)['*'] || params.name as string
403
451
 
452
+ // Read deep link params from URL search
453
+ const searchParams = new URLSearchParams(location.search)
454
+ const urlState = searchParams.get('state')
455
+ const urlStep = searchParams.get('step')
456
+
457
+ const [selectedState, setSelectedState] = React.useState<string | null>(urlState)
458
+
404
459
  if (!name) {
405
460
  return <Navigate to="/previews" />
406
461
  }
407
462
 
463
+ // Find the preview unit to determine the type
464
+ const unit = previewUnits.find(u => {
465
+ // Match by route suffix (e.g., "flows/onboarding" matches route "/_preview/flows/onboarding")
466
+ return u.route.endsWith(`/${name}`) || `${u.type}s/${u.name}` === name
467
+ })
468
+
469
+ // For flows, use the specialized component
470
+ if (unit?.type === 'flow') {
471
+ return (
472
+ <div className="preview-detail-page">
473
+ <FlowPreview unit={unit} initialStep={urlStep || undefined} />
474
+ </div>
475
+ )
476
+ }
477
+
478
+ // Get states for screens
479
+ const states = unit?.files?.states || []
480
+ const hasStates = states.length > 0
481
+
482
+ // For components and screens, use the generic Preview with iframe
408
483
  return (
409
484
  <div className="preview-detail-page">
410
- <Preview src={name} height="100%" showHeader />
485
+ {/* State selector for screens with multiple states */}
486
+ {hasStates && (
487
+ <div className="preview-state-selector">
488
+ <span className="preview-state-label">State:</span>
489
+ <div className="preview-state-buttons">
490
+ <button
491
+ type="button"
492
+ className={`preview-state-btn ${selectedState === null ? 'active' : ''}`}
493
+ onClick={() => setSelectedState(null)}
494
+ >
495
+ Default
496
+ </button>
497
+ {states.map((stateFile: string) => {
498
+ const stateName = stateFile.replace(/\.(tsx|jsx)$/, '')
499
+ const isSelected = selectedState === stateName
500
+ return (
501
+ <button
502
+ key={stateFile}
503
+ type="button"
504
+ className={`preview-state-btn ${isSelected ? 'active' : ''}`}
505
+ onClick={() => setSelectedState(stateName)}
506
+ >
507
+ {stateName.replace(/[-_]/g, ' ')}
508
+ </button>
509
+ )
510
+ })}
511
+ </div>
512
+ </div>
513
+ )}
514
+ <Preview src={name} state={selectedState} height="100%" showHeader />
411
515
  </div>
412
516
  )
413
517
  }
@@ -469,6 +573,14 @@ const tokensRoute = createRoute({
469
573
  component: TokensPage,
470
574
  })
471
575
 
576
+ // Standalone preview embed route (handles /_preview/flows/checkout, /_preview/atlas/app, etc.)
577
+ // This is used when thumbnails load as iframes - the 404.html serves the SPA which matches this route
578
+ const previewEmbedRoute = createRoute({
579
+ getParentRoute: () => rootRoute,
580
+ path: '/_preview/$',
581
+ component: PreviewEmbed,
582
+ })
583
+
472
584
  // Check if we have an index page (route '/')
473
585
  const hasIndexPage = pages.some((page: { route: string }) => page.route === '/')
474
586
  const firstPage = pages[0] as { route: string; file: string; title?: string; description?: string; frontmatter?: Record<string, unknown> } | undefined
@@ -502,20 +614,34 @@ function NotFoundPage() {
502
614
  }
503
615
 
504
616
  // Create router with notFoundRoute
617
+ // Snapshot compare route
618
+ function ComparePageComponent() {
619
+ const params = new URLSearchParams(window.location.search)
620
+ return <SnapshotCompare leftId={params.get('left') || undefined} rightId={params.get('right') || undefined} />
621
+ }
622
+
623
+ const compareRoute = createRoute({
624
+ getParentRoute: () => previewsLayoutRoute,
625
+ path: '_compare',
626
+ component: ComparePageComponent,
627
+ })
628
+
505
629
  // Previews routes: layout with catalog (index) and detail (splat) children
506
630
  const previewsRouteWithChildren = previewsLayoutRoute.addChildren([
507
631
  previewsCatalogRoute,
632
+ compareRoute,
508
633
  previewDetailRoute,
509
634
  ])
510
635
 
511
636
  const routeTree = rootRoute.addChildren([
512
637
  previewsRouteWithChildren,
513
638
  tokensRoute,
639
+ previewEmbedRoute,
514
640
  ...(indexRedirectRoute ? [indexRedirectRoute] : []),
515
641
  ...pageRoutes,
516
642
  ])
517
643
  // Get base path for subpath deployments (e.g., GitHub Pages)
518
- const basepath = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '') || '/'
644
+ const basepath = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '') || '/'
519
645
 
520
646
  const router = createRouter({
521
647
  routeTree,
@@ -0,0 +1,77 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { storage } from '../storage'
3
+ import type { Annotation, AnnotationComment, AnnotationCategory, UserIdentity } from '../types'
4
+
5
+ export function useAnnotations(previewName: string, stateOrStep: string) {
6
+ const storageKey = `annotations:${previewName}`
7
+
8
+ const [annotations, setAnnotations] = useState<Annotation[]>(
9
+ () => storage.get<Annotation[]>(storageKey) ?? []
10
+ )
11
+
12
+ const persist = (updated: Annotation[]) => {
13
+ storage.set(storageKey, updated)
14
+ setAnnotations(updated)
15
+ }
16
+
17
+ const getUser = (): string => {
18
+ const user = storage.get<UserIdentity>('user')
19
+ return user?.name || 'Anonymous'
20
+ }
21
+
22
+ const createAnnotation = useCallback((
23
+ x: number,
24
+ y: number,
25
+ category: AnnotationCategory,
26
+ text: string,
27
+ ) => {
28
+ const now = new Date().toISOString()
29
+ const annotation: Annotation = {
30
+ id: crypto.randomUUID(),
31
+ previewName,
32
+ stateOrStep,
33
+ x,
34
+ y,
35
+ category,
36
+ resolved: false,
37
+ createdAt: now,
38
+ comments: [{
39
+ id: crypto.randomUUID(),
40
+ author: getUser(),
41
+ text,
42
+ createdAt: now,
43
+ }],
44
+ }
45
+ const current = storage.get<Annotation[]>(storageKey) ?? []
46
+ persist([...current, annotation])
47
+ }, [previewName, stateOrStep, storageKey])
48
+
49
+ const addComment = useCallback((annotationId: string, text: string) => {
50
+ const current = storage.get<Annotation[]>(storageKey) ?? []
51
+ const comment: AnnotationComment = {
52
+ id: crypto.randomUUID(),
53
+ author: getUser(),
54
+ text,
55
+ createdAt: new Date().toISOString(),
56
+ }
57
+ persist(current.map(a =>
58
+ a.id === annotationId
59
+ ? { ...a, comments: [...a.comments, comment] }
60
+ : a
61
+ ))
62
+ }, [storageKey])
63
+
64
+ const resolveAnnotation = useCallback((annotationId: string) => {
65
+ const current = storage.get<Annotation[]>(storageKey) ?? []
66
+ persist(current.map(a =>
67
+ a.id === annotationId ? { ...a, resolved: !a.resolved } : a
68
+ ))
69
+ }, [storageKey])
70
+
71
+ const deleteAnnotation = useCallback((annotationId: string) => {
72
+ const current = storage.get<Annotation[]>(storageKey) ?? []
73
+ persist(current.filter(a => a.id !== annotationId))
74
+ }, [storageKey])
75
+
76
+ return { annotations, createAnnotation, addComment, resolveAnnotation, deleteAnnotation }
77
+ }