prev-cli 0.16.1 → 0.16.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,12 +1,14 @@
1
- import React, { useState, useEffect, useRef, useCallback } from 'react'
2
- import { Smartphone, Tablet, Monitor, SunMedium, Moon, Maximize2, Minimize2, GripVertical, SlidersHorizontal, X, Loader2 } from 'lucide-react'
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import { IconDeviceMobile, IconDeviceTablet, IconDeviceDesktop, IconArrowsMaximize, IconArrowsMinimize, IconAdjustmentsHorizontal, IconX, IconLoader2, IconArrowLeft } from '@tabler/icons-react'
3
+ import { Link } from '@tanstack/react-router'
3
4
  import type { PreviewConfig, PreviewMessage, BuildResult } from '../preview-runtime/types'
4
5
 
5
6
  interface PreviewProps {
6
7
  src: string
7
8
  height?: string | number
8
9
  title?: string
9
- mode?: 'wasm' | 'legacy' // 'wasm' uses browser bundling, 'legacy' uses Vite
10
+ mode?: 'wasm' | 'legacy'
11
+ showHeader?: boolean // Show full header with back button and devtools
10
12
  }
11
13
 
12
14
  type DeviceMode = 'mobile' | 'tablet' | 'desktop'
@@ -17,20 +19,11 @@ const DEVICE_WIDTHS: Record<DeviceMode, number | '100%'> = {
17
19
  desktop: '100%',
18
20
  }
19
21
 
20
- interface Position {
21
- x: number
22
- y: number
23
- }
24
-
25
- export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProps) {
22
+ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader = false }: PreviewProps) {
26
23
  const [isFullscreen, setIsFullscreen] = useState(false)
27
24
  const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop')
28
25
  const [customWidth, setCustomWidth] = useState<number | null>(null)
29
- const [isDarkMode, setIsDarkMode] = useState(false)
30
26
  const [showSlider, setShowSlider] = useState(false)
31
- const [pillPosition, setPillPosition] = useState<Position>({ x: 0, y: 0 })
32
- const [isDragging, setIsDragging] = useState(false)
33
- const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
34
27
 
35
28
  // WASM preview state
36
29
  const [buildStatus, setBuildStatus] = useState<'loading' | 'building' | 'ready' | 'error'>('loading')
@@ -38,8 +31,6 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
38
31
  const [buildError, setBuildError] = useState<string | null>(null)
39
32
 
40
33
  const iframeRef = useRef<HTMLIFrameElement>(null)
41
- const pillRef = useRef<HTMLDivElement>(null)
42
- const containerRef = useRef<HTMLDivElement>(null)
43
34
 
44
35
  // URL depends on mode
45
36
  const previewUrl = mode === 'wasm' ? '/_preview-runtime' : `/_preview/${src}`
@@ -48,6 +39,9 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
48
39
  // Calculate current width
49
40
  const currentWidth = customWidth ?? (DEVICE_WIDTHS[deviceMode] === '100%' ? null : DEVICE_WIDTHS[deviceMode] as number)
50
41
 
42
+ // Use master dark mode from document
43
+ const isDarkMode = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
44
+
51
45
  // Handle escape key and body scroll lock
52
46
  useEffect(() => {
53
47
  if (!isFullscreen) return
@@ -67,7 +61,7 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
67
61
  }
68
62
  }, [isFullscreen])
69
63
 
70
- // Toggle dark mode on iframe
64
+ // Apply dark mode to iframe content
71
65
  useEffect(() => {
72
66
  const iframe = iframeRef.current
73
67
  if (!iframe) return
@@ -83,11 +77,10 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
83
77
  }
84
78
  }
85
79
  } catch {
86
- // Cross-origin iframe, can't access
80
+ // Cross-origin iframe
87
81
  }
88
82
  }
89
83
 
90
- // Apply on load and when mode changes
91
84
  iframe.addEventListener('load', applyDarkMode)
92
85
  applyDarkMode()
93
86
 
@@ -105,12 +98,10 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
105
98
 
106
99
  let configSent = false
107
100
 
108
- // Handle messages from iframe
109
101
  const handleMessage = (event: MessageEvent) => {
110
102
  const msg = event.data as PreviewMessage
111
103
 
112
104
  if (msg.type === 'ready' && !configSent) {
113
- // Iframe is ready, fetch and send config
114
105
  configSent = true
115
106
  setBuildStatus('building')
116
107
 
@@ -150,62 +141,6 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
150
141
  }
151
142
  }, [mode, src])
152
143
 
153
- // Drag handlers
154
- const handleMouseDown = useCallback((e: React.MouseEvent) => {
155
- if (!pillRef.current) return
156
- e.preventDefault()
157
-
158
- const rect = pillRef.current.getBoundingClientRect()
159
- setDragOffset({
160
- x: e.clientX - rect.left,
161
- y: e.clientY - rect.top,
162
- })
163
- setIsDragging(true)
164
- }, [])
165
-
166
- useEffect(() => {
167
- if (!isDragging) return
168
-
169
- const handleMouseMove = (e: MouseEvent) => {
170
- const container = isFullscreen ? document.body : containerRef.current
171
- if (!container) return
172
-
173
- const containerRect = isFullscreen
174
- ? { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
175
- : container.getBoundingClientRect()
176
-
177
- const pillWidth = pillRef.current?.offsetWidth || 0
178
- const pillHeight = pillRef.current?.offsetHeight || 0
179
-
180
- // Calculate position relative to container
181
- let newX = e.clientX - containerRect.left - dragOffset.x
182
- let newY = e.clientY - containerRect.top - dragOffset.y
183
-
184
- // Constrain to container bounds
185
- newX = Math.max(0, Math.min(newX, containerRect.width - pillWidth))
186
- newY = Math.max(0, Math.min(newY, containerRect.height - pillHeight))
187
-
188
- setPillPosition({ x: newX, y: newY })
189
- }
190
-
191
- const handleMouseUp = () => {
192
- setIsDragging(false)
193
- }
194
-
195
- document.addEventListener('mousemove', handleMouseMove)
196
- document.addEventListener('mouseup', handleMouseUp)
197
-
198
- return () => {
199
- document.removeEventListener('mousemove', handleMouseMove)
200
- document.removeEventListener('mouseup', handleMouseUp)
201
- }
202
- }, [isDragging, dragOffset, isFullscreen])
203
-
204
- // Reset pill position when entering/exiting fullscreen
205
- useEffect(() => {
206
- setPillPosition({ x: 0, y: 0 })
207
- }, [isFullscreen])
208
-
209
144
  const handleDeviceChange = (mode: DeviceMode) => {
210
145
  setDeviceMode(mode)
211
146
  setCustomWidth(null)
@@ -214,10 +149,10 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
214
149
 
215
150
  const handleSliderChange = (value: number) => {
216
151
  setCustomWidth(value)
217
- setDeviceMode('desktop') // Clear device mode when using custom
152
+ setDeviceMode('desktop')
218
153
  }
219
154
 
220
- // Icon button component - using inline styles since Tailwind isn't enabled for docs
155
+ // Icon button component
221
156
  const IconButton = ({
222
157
  onClick,
223
158
  active,
@@ -240,8 +175,8 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
240
175
  alignItems: 'center',
241
176
  justifyContent: 'center',
242
177
  transition: 'background-color 0.15s, color 0.15s',
243
- backgroundColor: active ? '#3b82f6' : 'transparent',
244
- color: active ? '#fff' : '#71717a',
178
+ backgroundColor: active ? 'var(--fd-primary, #3b82f6)' : 'transparent',
179
+ color: active ? '#fff' : 'var(--fd-muted-foreground, #71717a)',
245
180
  }}
246
181
  title={btnTitle}
247
182
  aria-label={btnTitle}
@@ -250,147 +185,95 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
250
185
  </button>
251
186
  )
252
187
 
253
- // Floating DevTools Pill - using inline styles since Tailwind isn't enabled for docs
254
- const DevToolsPill = () => {
255
- const baseStyle: React.CSSProperties = {
256
- position: 'absolute',
257
- zIndex: 50,
258
- display: 'flex',
259
- alignItems: 'center',
260
- gap: '2px',
261
- padding: '4px 6px',
262
- backgroundColor: '#fff',
263
- borderRadius: '8px',
264
- boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)',
265
- border: '1px solid #e4e4e7',
266
- }
267
-
268
- const positionStyle: React.CSSProperties = pillPosition.x === 0 && pillPosition.y === 0
269
- ? { bottom: 12, right: 12 }
270
- : { left: pillPosition.x, top: pillPosition.y }
271
-
272
- const dividerStyle: React.CSSProperties = {
273
- width: '1px',
274
- height: '16px',
275
- backgroundColor: '#e4e4e7',
276
- margin: '0 2px',
277
- }
278
-
279
- return (
280
- <div
281
- ref={pillRef}
282
- style={{ ...baseStyle, ...positionStyle, cursor: isDragging ? 'grabbing' : undefined }}
188
+ // DevTools in header - device modes, width slider, fullscreen
189
+ const DevTools = () => (
190
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
191
+ <IconButton
192
+ onClick={() => handleDeviceChange('mobile')}
193
+ active={deviceMode === 'mobile' && customWidth === null}
194
+ title="Mobile (375px)"
283
195
  >
284
- {/* Drag handle */}
285
- <div
286
- onMouseDown={handleMouseDown}
287
- style={{ padding: '4px', cursor: 'grab', color: '#a1a1aa' }}
288
- title="Drag to move"
289
- >
290
- <GripVertical style={{ width: 12, height: 12 }} />
291
- </div>
196
+ <IconDeviceMobile size={16} />
197
+ </IconButton>
292
198
 
293
- <div style={dividerStyle} />
199
+ <IconButton
200
+ onClick={() => handleDeviceChange('tablet')}
201
+ active={deviceMode === 'tablet' && customWidth === null}
202
+ title="Tablet (768px)"
203
+ >
204
+ <IconDeviceTablet size={16} />
205
+ </IconButton>
294
206
 
295
- {/* Device modes */}
296
- <IconButton
297
- onClick={() => handleDeviceChange('mobile')}
298
- active={deviceMode === 'mobile' && customWidth === null}
299
- title="Mobile (375px)"
300
- >
301
- <Smartphone style={{ width: 14, height: 14 }} />
302
- </IconButton>
207
+ <IconButton
208
+ onClick={() => handleDeviceChange('desktop')}
209
+ active={deviceMode === 'desktop' && customWidth === null}
210
+ title="Desktop (100%)"
211
+ >
212
+ <IconDeviceDesktop size={16} />
213
+ </IconButton>
303
214
 
304
- <IconButton
305
- onClick={() => handleDeviceChange('tablet')}
306
- active={deviceMode === 'tablet' && customWidth === null}
307
- title="Tablet (768px)"
308
- >
309
- <Tablet style={{ width: 14, height: 14 }} />
310
- </IconButton>
215
+ <div style={{ width: '1px', height: '16px', backgroundColor: 'var(--fd-border, #e4e4e7)', margin: '0 4px' }} />
311
216
 
217
+ {/* Width slider toggle */}
218
+ <div style={{ position: 'relative' }}>
312
219
  <IconButton
313
- onClick={() => handleDeviceChange('desktop')}
314
- active={deviceMode === 'desktop' && customWidth === null}
315
- title="Desktop (100%)"
220
+ onClick={() => setShowSlider(!showSlider)}
221
+ active={showSlider || customWidth !== null}
222
+ title="Custom width"
316
223
  >
317
- <Monitor style={{ width: 14, height: 14 }} />
224
+ <IconAdjustmentsHorizontal size={16} />
318
225
  </IconButton>
319
226
 
320
- <div style={dividerStyle} />
321
-
322
- {/* Width slider toggle */}
323
- <div style={{ position: 'relative' }}>
324
- <IconButton
325
- onClick={() => setShowSlider(!showSlider)}
326
- active={showSlider || customWidth !== null}
327
- title="Custom width"
328
- >
329
- <SlidersHorizontal style={{ width: 14, height: 14 }} />
330
- </IconButton>
331
-
332
- {/* Slider popup */}
333
- {showSlider && (
334
- <div style={{
335
- position: 'absolute',
336
- bottom: '100%',
337
- left: '50%',
338
- transform: 'translateX(-50%)',
339
- marginBottom: '8px',
340
- padding: '12px',
341
- backgroundColor: '#fff',
342
- borderRadius: '8px',
343
- boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)',
344
- border: '1px solid #e4e4e7',
345
- minWidth: '192px',
346
- }}>
347
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
348
- <span style={{ fontSize: '12px', color: '#71717a' }}>Width: {customWidth ?? currentWidth ?? '100%'}px</span>
349
- <button
350
- onClick={() => setShowSlider(false)}
351
- style={{ padding: '2px', background: 'none', border: 'none', cursor: 'pointer', color: '#a1a1aa' }}
352
- >
353
- <X style={{ width: 12, height: 12 }} />
354
- </button>
355
- </div>
356
- <input
357
- type="range"
358
- min={320}
359
- max={1920}
360
- value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
361
- onChange={(e) => handleSliderChange(parseInt(e.target.value))}
362
- style={{ width: '100%', accentColor: '#3b82f6' }}
363
- />
364
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#a1a1aa', marginTop: '4px' }}>
365
- <span>320px</span>
366
- <span>1920px</span>
367
- </div>
227
+ {showSlider && (
228
+ <div style={{
229
+ position: 'absolute',
230
+ top: '100%',
231
+ right: 0,
232
+ marginTop: '8px',
233
+ padding: '12px',
234
+ backgroundColor: 'var(--fd-background, #fff)',
235
+ borderRadius: '8px',
236
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
237
+ border: '1px solid var(--fd-border, #e4e4e7)',
238
+ minWidth: '192px',
239
+ zIndex: 100,
240
+ }}>
241
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
242
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #71717a)' }}>
243
+ Width: {customWidth ?? currentWidth ?? '100%'}px
244
+ </span>
245
+ <button
246
+ onClick={() => setShowSlider(false)}
247
+ style={{ padding: '2px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fd-muted-foreground, #a1a1aa)' }}
248
+ >
249
+ <IconX size={12} />
250
+ </button>
368
251
  </div>
369
- )}
370
- </div>
371
-
372
- <div style={dividerStyle} />
373
-
374
- {/* Dark mode toggle */}
375
- <IconButton
376
- onClick={() => setIsDarkMode(!isDarkMode)}
377
- active={isDarkMode}
378
- title={isDarkMode ? 'Light mode' : 'Dark mode'}
379
- >
380
- {isDarkMode ? <Moon style={{ width: 14, height: 14 }} /> : <SunMedium style={{ width: 14, height: 14 }} />}
381
- </IconButton>
382
-
383
- {/* Fullscreen toggle */}
384
- <IconButton
385
- onClick={() => setIsFullscreen(!isFullscreen)}
386
- active={isFullscreen}
387
- title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
388
- >
389
- {isFullscreen ? <Minimize2 style={{ width: 14, height: 14 }} /> : <Maximize2 style={{ width: 14, height: 14 }} />}
390
- </IconButton>
252
+ <input
253
+ type="range"
254
+ min={320}
255
+ max={1920}
256
+ value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
257
+ onChange={(e) => handleSliderChange(parseInt(e.target.value))}
258
+ style={{ width: '100%', accentColor: 'var(--fd-primary, #3b82f6)' }}
259
+ />
260
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)', marginTop: '4px' }}>
261
+ <span>320px</span>
262
+ <span>1920px</span>
263
+ </div>
264
+ </div>
265
+ )}
391
266
  </div>
392
- )
393
- }
267
+
268
+ <IconButton
269
+ onClick={() => setIsFullscreen(!isFullscreen)}
270
+ active={isFullscreen}
271
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
272
+ >
273
+ {isFullscreen ? <IconArrowsMinimize size={16} /> : <IconArrowsMaximize size={16} />}
274
+ </IconButton>
275
+ </div>
276
+ )
394
277
 
395
278
  // Calculate iframe style
396
279
  const getIframeContainerStyle = (): React.CSSProperties => {
@@ -409,75 +292,179 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
409
292
  <div style={{
410
293
  position: 'fixed',
411
294
  inset: 0,
412
- zIndex: 40,
413
- backgroundColor: '#f4f4f5',
295
+ zIndex: 9999,
296
+ backgroundColor: 'var(--fd-muted, #f4f4f5)',
414
297
  display: 'flex',
415
- alignItems: 'flex-start',
416
- justifyContent: 'center',
417
- overflow: 'auto',
298
+ flexDirection: 'column',
418
299
  }}>
419
- {/* Checkered background pattern */}
420
- <div
421
- style={{
422
- position: 'absolute',
423
- inset: 0,
424
- opacity: 0.5,
425
- backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
426
- backgroundSize: '20px 20px',
427
- backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
428
- }}
429
- />
430
-
431
- {/* Iframe container */}
432
- <div
433
- style={{
434
- position: 'relative',
435
- backgroundColor: '#fff',
436
- boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)',
437
- transition: 'all 0.3s',
300
+ {/* Fullscreen header */}
301
+ <div style={{
302
+ display: 'flex',
303
+ alignItems: 'center',
304
+ justifyContent: 'space-between',
305
+ padding: '12px 16px',
306
+ backgroundColor: 'var(--fd-background, #fff)',
307
+ borderBottom: '1px solid var(--fd-border, #e4e4e7)',
308
+ }}>
309
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
310
+ <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--fd-foreground)' }}>
311
+ {displayTitle}
312
+ </span>
313
+ {mode === 'wasm' && buildTime && (
314
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>{buildTime}ms</span>
315
+ )}
316
+ </div>
317
+ <DevTools />
318
+ </div>
319
+
320
+ {/* Checkered background */}
321
+ <div style={{
322
+ flex: 1,
323
+ position: 'relative',
324
+ backgroundImage: 'linear-gradient(45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(-45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%), linear-gradient(-45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%)',
325
+ backgroundSize: '20px 20px',
326
+ backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
327
+ display: 'flex',
328
+ justifyContent: 'center',
329
+ }}>
330
+ <div style={{
331
+ backgroundColor: 'var(--fd-background, #fff)',
332
+ boxShadow: '0 4px 24px rgba(0,0,0,0.1)',
438
333
  height: '100%',
439
334
  ...getIframeContainerStyle(),
440
- }}
441
- >
442
- <iframe
443
- ref={iframeRef}
444
- src={previewUrl}
445
- style={{ width: '100%', height: '100%', border: 'none' }}
446
- title={displayTitle}
447
- />
335
+ }}>
336
+ <iframe
337
+ ref={iframeRef}
338
+ src={previewUrl}
339
+ style={{ width: '100%', height: '100%', border: 'none' }}
340
+ title={displayTitle}
341
+ />
342
+ </div>
448
343
  </div>
344
+ </div>
345
+ )
346
+ }
347
+
348
+ // Non-fullscreen with showHeader (individual preview page)
349
+ if (showHeader) {
350
+ return (
351
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
352
+ {/* Header with back button and devtools */}
353
+ <div style={{
354
+ display: 'flex',
355
+ alignItems: 'center',
356
+ justifyContent: 'space-between',
357
+ padding: '12px 16px',
358
+ backgroundColor: 'var(--fd-card, #fafafa)',
359
+ borderBottom: '1px solid var(--fd-border, #e4e4e7)',
360
+ borderRadius: '8px 8px 0 0',
361
+ }}>
362
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
363
+ <Link
364
+ to="/previews"
365
+ style={{
366
+ display: 'flex',
367
+ alignItems: 'center',
368
+ gap: '4px',
369
+ color: 'var(--fd-muted-foreground, #71717a)',
370
+ textDecoration: 'none',
371
+ fontSize: '14px',
372
+ }}
373
+ >
374
+ <IconArrowLeft size={16} />
375
+ Back
376
+ </Link>
377
+ <div style={{ width: '1px', height: '20px', backgroundColor: 'var(--fd-border, #e4e4e7)' }} />
378
+ <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--fd-foreground)' }}>
379
+ {displayTitle}
380
+ </span>
381
+ {mode === 'wasm' && buildStatus === 'building' && (
382
+ <IconLoader2 size={16} style={{ color: 'var(--fd-primary, #3b82f6)', animation: 'spin 1s linear infinite' }} />
383
+ )}
384
+ {mode === 'wasm' && buildTime && (
385
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>{buildTime}ms</span>
386
+ )}
387
+ </div>
388
+ <DevTools />
389
+ </div>
390
+
391
+ {/* Build error display */}
392
+ {mode === 'wasm' && buildError && (
393
+ <div style={{
394
+ padding: '8px 12px',
395
+ backgroundColor: '#fef2f2',
396
+ borderBottom: '1px solid #fecaca',
397
+ }}>
398
+ <pre style={{
399
+ fontSize: '12px',
400
+ color: '#dc2626',
401
+ whiteSpace: 'pre-wrap',
402
+ fontFamily: 'monospace',
403
+ overflow: 'auto',
404
+ maxHeight: '128px',
405
+ margin: 0,
406
+ }}>
407
+ {buildError}
408
+ </pre>
409
+ </div>
410
+ )}
449
411
 
450
- <DevToolsPill />
412
+ {/* Preview area with checkered background */}
413
+ <div style={{
414
+ flex: 1,
415
+ position: 'relative',
416
+ backgroundColor: 'var(--fd-muted, #f4f4f5)',
417
+ backgroundImage: 'linear-gradient(45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(-45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%), linear-gradient(-45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%)',
418
+ backgroundSize: '16px 16px',
419
+ backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
420
+ display: 'flex',
421
+ justifyContent: 'center',
422
+ }}>
423
+ <div style={{
424
+ backgroundColor: 'var(--fd-background, #fff)',
425
+ transition: 'all 0.3s',
426
+ ...getIframeContainerStyle(),
427
+ }}>
428
+ <iframe
429
+ ref={iframeRef}
430
+ src={previewUrl}
431
+ style={{
432
+ height: typeof height === 'number' ? `${height}px` : height,
433
+ width: '100%',
434
+ border: 'none',
435
+ }}
436
+ title={displayTitle}
437
+ />
438
+ </div>
439
+ </div>
451
440
  </div>
452
441
  )
453
442
  }
454
443
 
444
+ // Embedded preview (no header, compact)
455
445
  return (
456
- <div
457
- ref={containerRef}
458
- style={{
459
- margin: '16px 0',
460
- border: '1px solid #e4e4e7',
461
- borderRadius: '8px',
462
- overflow: 'hidden',
463
- position: 'relative',
464
- }}
465
- >
466
- {/* Header */}
446
+ <div style={{
447
+ margin: '16px 0',
448
+ border: '1px solid var(--fd-border, #e4e4e7)',
449
+ borderRadius: '8px',
450
+ overflow: 'hidden',
451
+ position: 'relative',
452
+ }}>
453
+ {/* Compact header */}
467
454
  <div style={{
468
455
  display: 'flex',
469
456
  alignItems: 'center',
470
457
  justifyContent: 'space-between',
471
458
  padding: '8px 12px',
472
- backgroundColor: '#fafafa',
473
- borderBottom: '1px solid #e4e4e7',
459
+ backgroundColor: 'var(--fd-card, #fafafa)',
460
+ borderBottom: '1px solid var(--fd-border, #e4e4e7)',
474
461
  }}>
475
462
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
476
- <span style={{ fontSize: '14px', fontWeight: 500, color: '#52525b' }}>
463
+ <span style={{ fontSize: '14px', fontWeight: 500, color: 'var(--fd-foreground, #52525b)' }}>
477
464
  {displayTitle}
478
465
  </span>
479
466
  {mode === 'wasm' && buildStatus === 'building' && (
480
- <Loader2 style={{ width: 14, height: 14, color: '#3b82f6', animation: 'spin 1s linear infinite' }} />
467
+ <IconLoader2 size={14} style={{ color: 'var(--fd-primary, #3b82f6)', animation: 'spin 1s linear infinite' }} />
481
468
  )}
482
469
  {mode === 'wasm' && buildStatus === 'error' && (
483
470
  <span style={{ fontSize: '12px', color: '#ef4444' }}>Error</span>
@@ -485,11 +472,12 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
485
472
  </div>
486
473
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
487
474
  {mode === 'wasm' && buildTime && (
488
- <span style={{ fontSize: '12px', color: '#a1a1aa' }}>{buildTime}ms</span>
475
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>{buildTime}ms</span>
489
476
  )}
490
- <span style={{ fontSize: '12px', color: '#a1a1aa' }}>
477
+ <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>
491
478
  {currentWidth ? `${currentWidth}px` : '100%'}
492
479
  </span>
480
+ <DevTools />
493
481
  </div>
494
482
  </div>
495
483
 
@@ -514,24 +502,21 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
514
502
  </div>
515
503
  )}
516
504
 
517
- {/* Preview area with checkered background */}
518
- <div
519
- style={{
520
- position: 'relative',
521
- backgroundColor: '#f4f4f5',
522
- backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
523
- backgroundSize: '16px 16px',
524
- backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
525
- }}
526
- >
527
- {/* Iframe container */}
528
- <div
529
- style={{
530
- backgroundColor: '#fff',
531
- transition: 'all 0.3s',
532
- ...getIframeContainerStyle(),
533
- }}
534
- >
505
+ {/* Preview area */}
506
+ <div style={{
507
+ position: 'relative',
508
+ backgroundColor: 'var(--fd-muted, #f4f4f5)',
509
+ backgroundImage: 'linear-gradient(45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(-45deg, var(--fd-border, #e5e5e5) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%), linear-gradient(-45deg, transparent 75%, var(--fd-border, #e5e5e5) 75%)',
510
+ backgroundSize: '16px 16px',
511
+ backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
512
+ display: 'flex',
513
+ justifyContent: 'center',
514
+ }}>
515
+ <div style={{
516
+ backgroundColor: 'var(--fd-background, #fff)',
517
+ transition: 'all 0.3s',
518
+ ...getIframeContainerStyle(),
519
+ }}>
535
520
  <iframe
536
521
  ref={iframeRef}
537
522
  src={previewUrl}
@@ -543,8 +528,6 @@ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProp
543
528
  title={displayTitle}
544
529
  />
545
530
  </div>
546
-
547
- <DevToolsPill />
548
531
  </div>
549
532
  </div>
550
533
  )
@@ -85,7 +85,7 @@ function PageWrapper({ Component, meta }: { Component: React.ComponentType; meta
85
85
  )
86
86
  }
87
87
 
88
- // Previews catalog - Storybook-like gallery
88
+ // Previews catalog - Storybook-like gallery with clickable cards
89
89
  function PreviewsCatalog() {
90
90
  if (!previews || previews.length === 0) {
91
91
  return (
@@ -116,53 +116,31 @@ function PreviewsCatalog() {
116
116
  Previews
117
117
  </h1>
118
118
  <p style={{ color: '#666' }}>
119
- {previews.length} component preview{previews.length !== 1 ? 's' : ''} available
119
+ {previews.length} component preview{previews.length !== 1 ? 's' : ''} available.
120
+ Click any preview to open it.
120
121
  </p>
121
122
  </div>
122
123
 
123
124
  <div style={{
124
125
  display: 'grid',
125
- gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
126
- gap: '24px',
126
+ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
127
+ gap: '20px',
127
128
  }}>
128
129
  {previews.map((preview: { name: string; route: string }) => (
129
- <div key={preview.name} style={{
130
- border: '1px solid #e4e4e7',
131
- borderRadius: '12px',
132
- overflow: 'hidden',
133
- backgroundColor: '#fff',
134
- }}>
135
- <div style={{
136
- padding: '12px 16px',
137
- borderBottom: '1px solid #e4e4e7',
138
- backgroundColor: '#fafafa',
139
- }}>
140
- <h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>
141
- {preview.name}
142
- </h2>
143
- <code style={{
144
- fontSize: '12px',
145
- color: '#666',
146
- fontFamily: 'monospace',
147
- }}>
148
- previews/{preview.name}/
149
- </code>
150
- </div>
151
- <Preview src={preview.name} height={300} />
152
- </div>
130
+ <PreviewCard key={preview.name} name={preview.name} />
153
131
  ))}
154
132
  </div>
155
133
 
156
134
  <div style={{
157
135
  marginTop: '40px',
158
136
  padding: '16px',
159
- backgroundColor: '#f0f9ff',
160
- border: '1px solid #bae6fd',
137
+ backgroundColor: 'var(--fd-muted)',
138
+ border: '1px solid var(--fd-border)',
161
139
  borderRadius: '8px',
162
140
  }}>
163
- <p style={{ margin: 0, fontSize: '14px', color: '#0369a1' }}>
141
+ <p style={{ margin: 0, fontSize: '14px', color: 'var(--fd-muted-foreground)' }}>
164
142
  <strong>Tip:</strong> Embed any preview in your MDX docs with{' '}
165
- <code style={{ backgroundColor: '#e0f2fe', padding: '2px 6px', borderRadius: '4px' }}>
143
+ <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px' }}>
166
144
  {'<Preview src="name" />'}
167
145
  </code>
168
146
  </p>
@@ -171,6 +149,82 @@ function PreviewsCatalog() {
171
149
  )
172
150
  }
173
151
 
152
+ // Individual preview card - clickable thumbnail
153
+ import { Link, useParams } from '@tanstack/react-router'
154
+
155
+ function PreviewCard({ name }: { name: string }) {
156
+ return (
157
+ <Link
158
+ to={`/previews/${name}`}
159
+ style={{
160
+ display: 'block',
161
+ border: '1px solid var(--fd-border)',
162
+ borderRadius: '12px',
163
+ overflow: 'hidden',
164
+ backgroundColor: 'var(--fd-background)',
165
+ textDecoration: 'none',
166
+ color: 'inherit',
167
+ transition: 'box-shadow 0.2s, transform 0.2s',
168
+ }}
169
+ onMouseOver={(e) => {
170
+ e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)'
171
+ e.currentTarget.style.transform = 'translateY(-2px)'
172
+ }}
173
+ onMouseOut={(e) => {
174
+ e.currentTarget.style.boxShadow = ''
175
+ e.currentTarget.style.transform = ''
176
+ }}
177
+ >
178
+ {/* Thumbnail preview */}
179
+ <div style={{
180
+ height: '180px',
181
+ overflow: 'hidden',
182
+ position: 'relative',
183
+ backgroundColor: 'var(--fd-muted)',
184
+ pointerEvents: 'none',
185
+ }}>
186
+ <iframe
187
+ src={`/_preview-runtime?src=${name}`}
188
+ style={{
189
+ width: '100%',
190
+ height: '100%',
191
+ border: 'none',
192
+ transform: 'scale(0.5)',
193
+ transformOrigin: 'top left',
194
+ width: '200%',
195
+ height: '200%',
196
+ }}
197
+ title={name}
198
+ loading="lazy"
199
+ />
200
+ </div>
201
+ {/* Card footer */}
202
+ <div style={{
203
+ padding: '12px 16px',
204
+ borderTop: '1px solid var(--fd-border)',
205
+ backgroundColor: 'var(--fd-card)',
206
+ }}>
207
+ <h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>
208
+ {name}
209
+ </h3>
210
+ <code style={{
211
+ fontSize: '11px',
212
+ color: 'var(--fd-muted-foreground)',
213
+ fontFamily: 'monospace',
214
+ }}>
215
+ previews/{name}/
216
+ </code>
217
+ </div>
218
+ </Link>
219
+ )
220
+ }
221
+
222
+ // Individual preview page - full view with devtools in header
223
+ function PreviewPage() {
224
+ const { name } = useParams({ from: '/previews/$name' })
225
+ return <Preview src={name} height="calc(100vh - 200px)" showHeader />
226
+ }
227
+
174
228
  // Root layout with custom lightweight Layout
175
229
  function RootLayout() {
176
230
  const pageTree = convertToPageTree(sidebar)
@@ -196,6 +250,13 @@ const previewsRoute = createRoute({
196
250
  component: PreviewsCatalog,
197
251
  })
198
252
 
253
+ // Individual preview route
254
+ const previewDetailRoute = createRoute({
255
+ getParentRoute: () => rootRoute,
256
+ path: '/previews/$name',
257
+ component: PreviewPage,
258
+ })
259
+
199
260
  // Create routes from pages
200
261
  const pageRoutes = pages.map((page: { route: string; file: string; title?: string; description?: string; frontmatter?: Record<string, unknown> }) => {
201
262
  const Component = getPageComponent(page.file)
@@ -212,7 +273,7 @@ const pageRoutes = pages.map((page: { route: string; file: string; title?: strin
212
273
  })
213
274
 
214
275
  // Create router
215
- const routeTree = rootRoute.addChildren([previewsRoute, ...pageRoutes])
276
+ const routeTree = rootRoute.addChildren([previewsRoute, previewDetailRoute, ...pageRoutes])
216
277
  const router = createRouter({ routeTree })
217
278
 
218
279
  // Mount app - RouterProvider must be outermost so TanStack Router context is available