@hustle-together/api-dev-tools 3.6.5 → 3.9.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.
Files changed (61) hide show
  1. package/README.md +5307 -258
  2. package/bin/cli.js +348 -20
  3. package/commands/README.md +459 -71
  4. package/commands/hustle-api-continue.md +158 -0
  5. package/commands/{api-create.md → hustle-api-create.md} +22 -2
  6. package/commands/{api-env.md → hustle-api-env.md} +4 -4
  7. package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
  8. package/commands/{api-research.md → hustle-api-research.md} +3 -3
  9. package/commands/hustle-api-sessions.md +149 -0
  10. package/commands/{api-status.md → hustle-api-status.md} +16 -16
  11. package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
  12. package/commands/hustle-combine.md +763 -0
  13. package/commands/hustle-ui-create.md +825 -0
  14. package/hooks/api-workflow-check.py +385 -19
  15. package/hooks/cache-research.py +337 -0
  16. package/hooks/check-playwright-setup.py +103 -0
  17. package/hooks/check-storybook-setup.py +81 -0
  18. package/hooks/detect-interruption.py +165 -0
  19. package/hooks/enforce-brand-guide.py +131 -0
  20. package/hooks/enforce-documentation.py +60 -8
  21. package/hooks/enforce-freshness.py +184 -0
  22. package/hooks/enforce-questions-sourced.py +146 -0
  23. package/hooks/enforce-schema-from-interview.py +248 -0
  24. package/hooks/enforce-ui-disambiguation.py +108 -0
  25. package/hooks/enforce-ui-interview.py +130 -0
  26. package/hooks/generate-manifest-entry.py +981 -0
  27. package/hooks/session-logger.py +297 -0
  28. package/hooks/session-startup.py +65 -10
  29. package/hooks/track-scope-coverage.py +220 -0
  30. package/hooks/track-tool-use.py +81 -1
  31. package/hooks/update-api-showcase.py +149 -0
  32. package/hooks/update-registry.py +352 -0
  33. package/hooks/update-ui-showcase.py +148 -0
  34. package/package.json +8 -2
  35. package/templates/BRAND_GUIDE.md +299 -0
  36. package/templates/CLAUDE-SECTION.md +56 -24
  37. package/templates/SPEC.json +640 -0
  38. package/templates/api-dev-state.json +179 -161
  39. package/templates/api-showcase/APICard.tsx +153 -0
  40. package/templates/api-showcase/APIModal.tsx +375 -0
  41. package/templates/api-showcase/APIShowcase.tsx +231 -0
  42. package/templates/api-showcase/APITester.tsx +522 -0
  43. package/templates/api-showcase/page.tsx +41 -0
  44. package/templates/component/Component.stories.tsx +172 -0
  45. package/templates/component/Component.test.tsx +237 -0
  46. package/templates/component/Component.tsx +86 -0
  47. package/templates/component/Component.types.ts +55 -0
  48. package/templates/component/index.ts +15 -0
  49. package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
  50. package/templates/dev-tools/page.tsx +10 -0
  51. package/templates/page/page.e2e.test.ts +218 -0
  52. package/templates/page/page.tsx +42 -0
  53. package/templates/performance-budgets.json +58 -0
  54. package/templates/registry.json +13 -0
  55. package/templates/settings.json +74 -0
  56. package/templates/shared/HeroHeader.tsx +261 -0
  57. package/templates/shared/index.ts +1 -0
  58. package/templates/ui-showcase/PreviewCard.tsx +315 -0
  59. package/templates/ui-showcase/PreviewModal.tsx +676 -0
  60. package/templates/ui-showcase/UIShowcase.tsx +262 -0
  61. package/templates/ui-showcase/page.tsx +26 -0
@@ -0,0 +1,676 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useCallback, useState } from 'react';
4
+ import { Sandpack, SandpackTheme } from '@codesandbox/sandpack-react';
5
+
6
+ interface RegistryItem {
7
+ name: string;
8
+ description?: string;
9
+ file?: string;
10
+ route?: string;
11
+ story?: string;
12
+ tests?: string;
13
+ variants?: string[];
14
+ status?: string;
15
+ created_at?: string;
16
+ uses_components?: string[];
17
+ props_interface?: string;
18
+ }
19
+
20
+ interface PreviewModalProps {
21
+ id: string;
22
+ type: 'component' | 'page';
23
+ data: RegistryItem;
24
+ onClose: () => void;
25
+ }
26
+
27
+ type ViewportSize = 'desktop' | 'tablet' | 'mobile';
28
+
29
+ const VIEWPORT_WIDTHS: Record<ViewportSize, string> = {
30
+ desktop: '100%',
31
+ tablet: '768px',
32
+ mobile: '375px',
33
+ };
34
+
35
+ /**
36
+ * Preview Modal Component
37
+ *
38
+ * Displays a modal with live preview of a component or page.
39
+ * Features:
40
+ * - Components: Dynamic import with error boundary isolation
41
+ * - Pages: Iframe with responsive viewport controls
42
+ * - Variant switching for components
43
+ *
44
+ * Created with Hustle UI Create workflow (v3.9.2)
45
+ */
46
+ export function PreviewModal({ id, type, data, onClose }: PreviewModalProps) {
47
+ // Close on Escape key
48
+ const handleKeyDown = useCallback(
49
+ (e: KeyboardEvent) => {
50
+ if (e.key === 'Escape') {
51
+ onClose();
52
+ }
53
+ },
54
+ [onClose]
55
+ );
56
+
57
+ useEffect(() => {
58
+ document.addEventListener('keydown', handleKeyDown);
59
+ document.body.style.overflow = 'hidden';
60
+
61
+ return () => {
62
+ document.removeEventListener('keydown', handleKeyDown);
63
+ document.body.style.overflow = '';
64
+ };
65
+ }, [handleKeyDown]);
66
+
67
+ // Get page route from file path
68
+ const getPageRoute = () => {
69
+ if (data.route) return data.route;
70
+ if (data.file?.includes('src/app/')) {
71
+ const match = data.file.match(/src\/app\/(.+?)\/page\.tsx?$/);
72
+ if (match) return `/${match[1]}`;
73
+ }
74
+ return `/${id}`;
75
+ };
76
+
77
+ return (
78
+ <div
79
+ className="fixed inset-0 z-50 flex items-center justify-center"
80
+ role="dialog"
81
+ aria-modal="true"
82
+ aria-labelledby="modal-title"
83
+ >
84
+ {/* Backdrop */}
85
+ <div
86
+ className="absolute inset-0 bg-black/80 backdrop-blur-sm"
87
+ onClick={onClose}
88
+ aria-hidden="true"
89
+ />
90
+
91
+ {/* Modal Content */}
92
+ <div className="relative z-10 flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden border-2 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,0.1)] dark:border-gray-700 dark:bg-gray-900">
93
+ {/* Header - Hustle accent bar */}
94
+ <div className="h-1 w-full bg-[#BA0C2F]" />
95
+ <header className="flex items-center justify-between border-b-2 border-black px-6 py-4 dark:border-gray-700">
96
+ <div>
97
+ <h2 id="modal-title" className="text-lg font-bold text-black dark:text-white">
98
+ {data.name || id}
99
+ </h2>
100
+ <p className="text-sm text-gray-600 dark:text-gray-400">
101
+ {type === 'component' ? 'Component Preview' : 'Page Preview'}
102
+ </p>
103
+ </div>
104
+ <button
105
+ onClick={onClose}
106
+ className="border-2 border-black p-2 transition-colors hover:border-[#BA0C2F] hover:bg-gray-50 dark:border-gray-600 dark:text-white dark:hover:bg-gray-800"
107
+ aria-label="Close preview"
108
+ >
109
+ <svg
110
+ xmlns="http://www.w3.org/2000/svg"
111
+ width="20"
112
+ height="20"
113
+ viewBox="0 0 24 24"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ strokeWidth="2"
117
+ strokeLinecap="round"
118
+ strokeLinejoin="round"
119
+ >
120
+ <path d="M18 6 6 18" />
121
+ <path d="m6 6 12 12" />
122
+ </svg>
123
+ </button>
124
+ </header>
125
+
126
+ {/* Preview Area */}
127
+ <div className="flex-1 overflow-auto">
128
+ {type === 'component' ? (
129
+ <ComponentPreview id={id} data={data} />
130
+ ) : (
131
+ <PagePreview route={getPageRoute()} />
132
+ )}
133
+ </div>
134
+
135
+ {/* Footer */}
136
+ <footer className="border-t-2 border-black bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
137
+ <div className="flex flex-wrap items-center justify-between gap-4">
138
+ {/* Info */}
139
+ <div className="text-sm text-gray-600 dark:text-gray-400">
140
+ {data.description && (
141
+ <p className="line-clamp-1">{data.description}</p>
142
+ )}
143
+ {data.created_at && (
144
+ <p className="mt-1">Created: {data.created_at}</p>
145
+ )}
146
+ </div>
147
+
148
+ {/* Actions */}
149
+ <div className="flex gap-2">
150
+ {type === 'page' && (
151
+ <a
152
+ href={getPageRoute()}
153
+ target="_blank"
154
+ rel="noopener noreferrer"
155
+ className="border-2 border-black bg-white px-3 py-1.5 text-sm font-medium text-black transition-colors hover:border-[#BA0C2F] hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
156
+ >
157
+ Open Full Page
158
+ </a>
159
+ )}
160
+ <button
161
+ onClick={() => {
162
+ const importPath = data.file?.replace(/^src\//, '@/').replace(/\.tsx?$/, '');
163
+ if (importPath) {
164
+ navigator.clipboard.writeText(
165
+ `import { ${data.name || id} } from '${importPath}';`
166
+ );
167
+ }
168
+ }}
169
+ className="border-2 border-black bg-[#BA0C2F] px-3 py-1.5 text-sm font-bold text-white transition-colors hover:bg-[#8a0923]"
170
+ >
171
+ Copy Import
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </footer>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ // Hustle Together theme for Sandpack
182
+ const hustleTheme: SandpackTheme = {
183
+ colors: {
184
+ surface1: '#ffffff',
185
+ surface2: '#f8f8f8',
186
+ surface3: '#f0f0f0',
187
+ clickable: '#666666',
188
+ base: '#000000',
189
+ disabled: '#cccccc',
190
+ hover: '#BA0C2F',
191
+ accent: '#BA0C2F',
192
+ error: '#ef4444',
193
+ errorSurface: '#fef2f2',
194
+ },
195
+ syntax: {
196
+ plain: '#000000',
197
+ comment: { color: '#666666', fontStyle: 'italic' },
198
+ keyword: '#BA0C2F',
199
+ tag: '#BA0C2F',
200
+ punctuation: '#000000',
201
+ definition: '#000000',
202
+ property: '#BA0C2F',
203
+ static: '#BA0C2F',
204
+ string: '#22c55e',
205
+ },
206
+ font: {
207
+ body: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif',
208
+ mono: '"SF Mono", Monaco, Inconsolata, "Fira Code", monospace',
209
+ size: '13px',
210
+ lineHeight: '1.5',
211
+ },
212
+ };
213
+
214
+ // Generate example code for different component types
215
+ function generateComponentCode(name: string, variants: string[], selectedVariant: string | null): string {
216
+ const variant = selectedVariant || variants[0] || 'primary';
217
+
218
+ // Generate code based on component type
219
+ switch (name.toLowerCase()) {
220
+ case 'button':
221
+ return `export default function App() {
222
+ return (
223
+ <div style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
224
+ <h2 style={{ margin: 0, fontFamily: 'system-ui' }}>Button - ${variant}</h2>
225
+
226
+ {/* ${variant} variant */}
227
+ <button style={{
228
+ padding: '0.75rem 1.5rem',
229
+ fontSize: '14px',
230
+ fontWeight: 'bold',
231
+ border: '2px solid ${variant === 'ghost' ? '#000' : '#BA0C2F'}',
232
+ background: '${variant === 'ghost' ? 'transparent' : variant === 'secondary' ? '#fff' : '#BA0C2F'}',
233
+ color: '${variant === 'ghost' || variant === 'secondary' ? '#000' : '#fff'}',
234
+ cursor: 'pointer',
235
+ }}>
236
+ Click Me
237
+ </button>
238
+
239
+ {/* All variants */}
240
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
241
+ ${variants.map(v => `<button style={{
242
+ padding: '0.5rem 1rem',
243
+ fontSize: '12px',
244
+ fontWeight: 'bold',
245
+ border: '2px solid ${v === 'ghost' ? '#000' : '#BA0C2F'}',
246
+ background: '${v === 'ghost' ? 'transparent' : v === 'secondary' ? '#fff' : '#BA0C2F'}',
247
+ color: '${v === 'ghost' || v === 'secondary' ? '#000' : '#fff'}',
248
+ cursor: 'pointer',
249
+ }}>${v}</button>`).join('\n ')}
250
+ </div>
251
+ </div>
252
+ );
253
+ }`;
254
+
255
+ case 'card':
256
+ return `export default function App() {
257
+ return (
258
+ <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
259
+ <h2 style={{ margin: '0 0 1rem' }}>Card - ${variant}</h2>
260
+
261
+ <div style={{
262
+ border: '${variant === 'bordered' ? '2px solid #000' : '1px solid #ccc'}',
263
+ boxShadow: '${variant === 'elevated' ? '4px 4px 0 rgba(0,0,0,0.1)' : 'none'}',
264
+ background: '#fff',
265
+ maxWidth: '320px',
266
+ }}>
267
+ {/* Header */}
268
+ <div style={{ padding: '1rem', borderBottom: '1px solid #eee' }}>
269
+ <h3 style={{ margin: 0, fontWeight: 'bold' }}>Card Title</h3>
270
+ </div>
271
+
272
+ {/* Body */}
273
+ <div style={{ padding: '1rem' }}>
274
+ <p style={{ margin: 0, color: '#666' }}>
275
+ This is a ${variant} card variant. Cards are used to group related content.
276
+ </p>
277
+ </div>
278
+
279
+ {/* Footer */}
280
+ <div style={{ padding: '1rem', borderTop: '1px solid #eee', background: '#f8f8f8' }}>
281
+ <button style={{
282
+ padding: '0.5rem 1rem',
283
+ background: '#BA0C2F',
284
+ color: '#fff',
285
+ border: 'none',
286
+ fontWeight: 'bold',
287
+ cursor: 'pointer',
288
+ }}>Action</button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ );
293
+ }`;
294
+
295
+ case 'formfield':
296
+ return `import { useState } from 'react';
297
+
298
+ export default function App() {
299
+ const [value, setValue] = useState('');
300
+ const [error, setError] = useState('');
301
+
302
+ const handleChange = (e) => {
303
+ setValue(e.target.value);
304
+ setError(e.target.value.length < 3 ? 'Must be at least 3 characters' : '');
305
+ };
306
+
307
+ return (
308
+ <div style={{ padding: '2rem', fontFamily: 'system-ui', maxWidth: '320px' }}>
309
+ <h2 style={{ margin: '0 0 1rem' }}>FormField - ${variant}</h2>
310
+
311
+ <div style={{ marginBottom: '1rem' }}>
312
+ <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold', fontSize: '14px' }}>
313
+ ${variant === 'email' ? 'Email Address' : variant === 'password' ? 'Password' : variant === 'textarea' ? 'Message' : 'Username'}
314
+ </label>
315
+
316
+ ${variant === 'textarea' ? `<textarea
317
+ value={value}
318
+ onChange={handleChange}
319
+ placeholder="Enter your message..."
320
+ rows={4}
321
+ style={{
322
+ width: '100%',
323
+ padding: '0.75rem',
324
+ border: error ? '2px solid #ef4444' : '2px solid #000',
325
+ fontSize: '14px',
326
+ fontFamily: 'inherit',
327
+ boxSizing: 'border-box',
328
+ }}
329
+ />` : `<input
330
+ type="${variant}"
331
+ value={value}
332
+ onChange={handleChange}
333
+ placeholder="${variant === 'email' ? 'you@example.com' : variant === 'password' ? '••••••••' : 'Enter text...'}"
334
+ style={{
335
+ width: '100%',
336
+ padding: '0.75rem',
337
+ border: error ? '2px solid #ef4444' : '2px solid #000',
338
+ fontSize: '14px',
339
+ boxSizing: 'border-box',
340
+ }}
341
+ />`}
342
+
343
+ {error && (
344
+ <p style={{ color: '#ef4444', fontSize: '12px', marginTop: '0.5rem' }}>
345
+ {error}
346
+ </p>
347
+ )}
348
+
349
+ <p style={{ color: '#666', fontSize: '12px', marginTop: '0.5rem' }}>
350
+ Helper text for the input field
351
+ </p>
352
+ </div>
353
+ </div>
354
+ );
355
+ }`;
356
+
357
+ default:
358
+ // Generic component preview
359
+ return `export default function App() {
360
+ return (
361
+ <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
362
+ <h2 style={{ margin: '0 0 1rem' }}>${name}</h2>
363
+
364
+ <div style={{
365
+ border: '2px solid #000',
366
+ padding: '2rem',
367
+ textAlign: 'center',
368
+ background: '#f8f8f8',
369
+ }}>
370
+ <div style={{
371
+ width: '64px',
372
+ height: '64px',
373
+ margin: '0 auto 1rem',
374
+ border: '2px solid #BA0C2F',
375
+ display: 'flex',
376
+ alignItems: 'center',
377
+ justifyContent: 'center',
378
+ background: '#fff',
379
+ }}>
380
+ <span style={{ fontSize: '24px' }}>⬛</span>
381
+ </div>
382
+
383
+ <p style={{ margin: 0, fontWeight: 'bold' }}>${name} Component</p>
384
+ ${selectedVariant ? `<p style={{ margin: '0.5rem 0 0', color: '#BA0C2F', fontSize: '14px' }}>Variant: ${selectedVariant}</p>` : ''}
385
+
386
+ <p style={{ margin: '1rem 0 0', color: '#666', fontSize: '14px' }}>
387
+ Edit the code on the left to customize this component
388
+ </p>
389
+ </div>
390
+ </div>
391
+ );
392
+ }`;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Component Preview with Sandpack
398
+ *
399
+ * Uses CodeSandbox's Sandpack to render live, editable component previews.
400
+ * No server/client boundary issues - runs entirely in the browser.
401
+ */
402
+ function ComponentPreview({
403
+ id,
404
+ data,
405
+ }: {
406
+ id: string;
407
+ data: RegistryItem;
408
+ }) {
409
+ const [selectedVariant, setSelectedVariant] = useState<string | null>(
410
+ data.variants?.[0] || null
411
+ );
412
+
413
+ const componentCode = generateComponentCode(
414
+ data.name || id,
415
+ data.variants || [],
416
+ selectedVariant
417
+ );
418
+
419
+ return (
420
+ <div className="p-4">
421
+ {/* Variant Controls */}
422
+ {data.variants && data.variants.length > 0 && (
423
+ <div className="mb-4">
424
+ <h3 className="mb-3 text-sm font-bold text-black dark:text-white">Variants</h3>
425
+ <div className="flex flex-wrap gap-2">
426
+ {data.variants.map((variant) => (
427
+ <button
428
+ key={variant}
429
+ onClick={() => setSelectedVariant(variant)}
430
+ className={`border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
431
+ selectedVariant === variant
432
+ ? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
433
+ : 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
434
+ }`}
435
+ >
436
+ {variant}
437
+ </button>
438
+ ))}
439
+ </div>
440
+ </div>
441
+ )}
442
+
443
+ {/* Sandpack Live Preview */}
444
+ <div className="border-2 border-black dark:border-gray-700">
445
+ <Sandpack
446
+ template="react"
447
+ theme={hustleTheme}
448
+ files={{
449
+ '/App.js': componentCode,
450
+ }}
451
+ options={{
452
+ showNavigator: false,
453
+ showTabs: true,
454
+ showLineNumbers: true,
455
+ showInlineErrors: true,
456
+ editorHeight: 350,
457
+ }}
458
+ />
459
+ </div>
460
+
461
+ {/* Component Info */}
462
+ <div className="mt-4 grid gap-4 md:grid-cols-2">
463
+ {data.props_interface && (
464
+ <div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
465
+ <h3 className="mb-2 text-sm font-bold text-black dark:text-white">Props Interface</h3>
466
+ <code className="font-mono text-sm text-gray-700 dark:text-gray-300">{data.props_interface}</code>
467
+ </div>
468
+ )}
469
+
470
+ {data.file && (
471
+ <div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
472
+ <h3 className="mb-2 text-sm font-bold text-black dark:text-white">File Location</h3>
473
+ <code className="font-mono text-sm text-gray-700 dark:text-gray-300">{data.file}</code>
474
+ </div>
475
+ )}
476
+
477
+ {data.uses_components && data.uses_components.length > 0 && (
478
+ <div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
479
+ <h3 className="mb-2 text-sm font-bold text-black dark:text-white">Uses Components</h3>
480
+ <div className="flex flex-wrap gap-1">
481
+ {data.uses_components.map((comp) => (
482
+ <span
483
+ key={comp}
484
+ className="border border-gray-300 bg-gray-50 px-2 py-0.5 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
485
+ >
486
+ {comp}
487
+ </span>
488
+ ))}
489
+ </div>
490
+ </div>
491
+ )}
492
+
493
+ <div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
494
+ <h3 className="mb-2 text-sm font-bold text-black dark:text-white">Powered by</h3>
495
+ <p className="text-sm text-gray-600 dark:text-gray-400">
496
+ <a
497
+ href="https://sandpack.codesandbox.io/"
498
+ target="_blank"
499
+ rel="noopener noreferrer"
500
+ className="text-[#BA0C2F] hover:underline"
501
+ >
502
+ Sandpack
503
+ </a> by CodeSandbox - Edit the code live!
504
+ </p>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ );
509
+ }
510
+
511
+ /**
512
+ * Page Preview
513
+ *
514
+ * Renders the page in an iframe with responsive viewport controls.
515
+ * Checks if the route exists before rendering to avoid 404s.
516
+ */
517
+ function PagePreview({ route }: { route: string }) {
518
+ const [viewport, setViewport] = useState<ViewportSize>('desktop');
519
+ const [routeStatus, setRouteStatus] = useState<'checking' | 'exists' | 'not-found'>('checking');
520
+
521
+ // Check if the route exists
522
+ useEffect(() => {
523
+ const checkRoute = async () => {
524
+ try {
525
+ const res = await fetch(route, { method: 'HEAD' });
526
+ setRouteStatus(res.ok ? 'exists' : 'not-found');
527
+ } catch {
528
+ setRouteStatus('not-found');
529
+ }
530
+ };
531
+ checkRoute();
532
+ }, [route]);
533
+
534
+ return (
535
+ <div className="p-4">
536
+ {/* Responsive Size Controls */}
537
+ <div className="mb-4 flex justify-center gap-2">
538
+ <button
539
+ onClick={() => setViewport('desktop')}
540
+ className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
541
+ viewport === 'desktop'
542
+ ? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
543
+ : 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
544
+ }`}
545
+ >
546
+ <svg
547
+ xmlns="http://www.w3.org/2000/svg"
548
+ width="16"
549
+ height="16"
550
+ viewBox="0 0 24 24"
551
+ fill="none"
552
+ stroke="currentColor"
553
+ strokeWidth="2"
554
+ strokeLinecap="round"
555
+ strokeLinejoin="round"
556
+ >
557
+ <rect width="20" height="14" x="2" y="3" rx="2" />
558
+ <line x1="8" x2="16" y1="21" y2="21" />
559
+ <line x1="12" x2="12" y1="17" y2="21" />
560
+ </svg>
561
+ Desktop
562
+ </button>
563
+ <button
564
+ onClick={() => setViewport('tablet')}
565
+ className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
566
+ viewport === 'tablet'
567
+ ? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
568
+ : 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
569
+ }`}
570
+ >
571
+ <svg
572
+ xmlns="http://www.w3.org/2000/svg"
573
+ width="16"
574
+ height="16"
575
+ viewBox="0 0 24 24"
576
+ fill="none"
577
+ stroke="currentColor"
578
+ strokeWidth="2"
579
+ strokeLinecap="round"
580
+ strokeLinejoin="round"
581
+ >
582
+ <rect width="16" height="20" x="4" y="2" rx="2" />
583
+ <line x1="12" x2="12.01" y1="18" y2="18" />
584
+ </svg>
585
+ Tablet
586
+ </button>
587
+ <button
588
+ onClick={() => setViewport('mobile')}
589
+ className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
590
+ viewport === 'mobile'
591
+ ? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
592
+ : 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
593
+ }`}
594
+ >
595
+ <svg
596
+ xmlns="http://www.w3.org/2000/svg"
597
+ width="16"
598
+ height="16"
599
+ viewBox="0 0 24 24"
600
+ fill="none"
601
+ stroke="currentColor"
602
+ strokeWidth="2"
603
+ strokeLinecap="round"
604
+ strokeLinejoin="round"
605
+ >
606
+ <rect width="14" height="20" x="5" y="2" rx="2" />
607
+ <line x1="12" x2="12.01" y1="18" y2="18" />
608
+ </svg>
609
+ Mobile
610
+ </button>
611
+ </div>
612
+
613
+ {/* Content Area */}
614
+ <div
615
+ className="mx-auto overflow-hidden border-2 border-black bg-white transition-all duration-300 dark:border-gray-700"
616
+ style={{ width: VIEWPORT_WIDTHS[viewport] }}
617
+ >
618
+ {routeStatus === 'checking' ? (
619
+ <div className="flex h-[500px] items-center justify-center bg-gray-50 dark:bg-gray-800">
620
+ <div className="text-center">
621
+ <div className="mx-auto mb-4 h-8 w-8 animate-spin border-4 border-gray-300 border-t-[#BA0C2F]" style={{ borderRadius: '50%' }} />
622
+ <p className="text-sm text-gray-600 dark:text-gray-400">Checking route...</p>
623
+ </div>
624
+ </div>
625
+ ) : routeStatus === 'not-found' ? (
626
+ <div className="flex h-[500px] items-center justify-center bg-gray-50 dark:bg-gray-800">
627
+ <div className="max-w-sm p-8 text-center">
628
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center border-2 border-black bg-gray-100 dark:border-gray-600 dark:bg-gray-700">
629
+ <svg
630
+ xmlns="http://www.w3.org/2000/svg"
631
+ width="32"
632
+ height="32"
633
+ viewBox="0 0 24 24"
634
+ fill="none"
635
+ stroke="currentColor"
636
+ strokeWidth="2"
637
+ strokeLinecap="round"
638
+ strokeLinejoin="round"
639
+ className="text-gray-400"
640
+ >
641
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
642
+ <polyline points="14 2 14 8 20 8" />
643
+ <line x1="9" x2="15" y1="15" y2="15" />
644
+ </svg>
645
+ </div>
646
+ <h3 className="mb-2 font-bold text-black dark:text-white">Page Not Found</h3>
647
+ <p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
648
+ The route <code className="border border-gray-300 bg-gray-100 px-1 dark:border-gray-600 dark:bg-gray-700">{route}</code> doesn&apos;t exist yet.
649
+ </p>
650
+ <p className="text-xs text-gray-500 dark:text-gray-400">
651
+ Create the page at <code className="text-[#BA0C2F]">src/app{route}/page.tsx</code> to see the preview.
652
+ </p>
653
+ </div>
654
+ </div>
655
+ ) : (
656
+ <iframe
657
+ src={route}
658
+ title="Page Preview"
659
+ className="h-[500px] w-full"
660
+ loading="lazy"
661
+ />
662
+ )}
663
+ </div>
664
+
665
+ {/* Viewport Info */}
666
+ <p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
667
+ Viewport: {VIEWPORT_WIDTHS[viewport]} • Route: {route}
668
+ {routeStatus === 'not-found' && (
669
+ <span className="ml-2 border border-yellow-400 bg-yellow-50 px-2 py-0.5 text-xs text-yellow-700 dark:border-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400">
670
+ Route does not exist
671
+ </span>
672
+ )}
673
+ </p>
674
+ </div>
675
+ );
676
+ }