prev-cli 0.24.20 → 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 -1714
  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
@@ -0,0 +1,68 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import { Icon } from '../icons'
3
+
4
+ interface SnapshotButtonProps {
5
+ onCapture: () => void
6
+ }
7
+
8
+ export function SnapshotButton({ onCapture }: SnapshotButtonProps) {
9
+ const [flashing, setFlashing] = useState(false)
10
+
11
+ const handleClick = useCallback(() => {
12
+ onCapture()
13
+ setFlashing(true)
14
+ setTimeout(() => setFlashing(false), 300)
15
+ }, [onCapture])
16
+
17
+ return (
18
+ <div style={{ position: 'relative', display: 'inline-flex' }}>
19
+ <button
20
+ onClick={handleClick}
21
+ style={{
22
+ display: 'flex',
23
+ alignItems: 'center',
24
+ justifyContent: 'center',
25
+ padding: '6px',
26
+ border: 'none',
27
+ borderRadius: '8px',
28
+ cursor: 'pointer',
29
+ backgroundColor: 'var(--fd-muted)',
30
+ color: 'var(--fd-muted-foreground)',
31
+ transition: 'all 0.15s ease',
32
+ }}
33
+ onMouseEnter={e => {
34
+ e.currentTarget.style.backgroundColor = 'var(--fd-secondary)'
35
+ e.currentTarget.style.color = 'var(--fd-foreground)'
36
+ }}
37
+ onMouseLeave={e => {
38
+ e.currentTarget.style.backgroundColor = 'var(--fd-muted)'
39
+ e.currentTarget.style.color = 'var(--fd-muted-foreground)'
40
+ }}
41
+ title="Capture snapshot"
42
+ >
43
+ <Icon name="camera" size={14} />
44
+ </button>
45
+
46
+ {/* Flash overlay */}
47
+ {flashing && (
48
+ <div
49
+ style={{
50
+ position: 'fixed',
51
+ inset: 0,
52
+ backgroundColor: 'white',
53
+ zIndex: 9999,
54
+ pointerEvents: 'none',
55
+ animation: 'snapshot-flash 300ms ease-out forwards',
56
+ }}
57
+ />
58
+ )}
59
+
60
+ <style>{`
61
+ @keyframes snapshot-flash {
62
+ 0% { opacity: 0.6; }
63
+ 100% { opacity: 0; }
64
+ }
65
+ `}</style>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,216 @@
1
+ import React, { useState, useMemo } from 'react'
2
+ import { useSnapshots } from '../hooks/useSnapshots'
3
+ import { Icon } from '../icons'
4
+
5
+ interface SnapshotCompareProps {
6
+ leftId?: string
7
+ rightId?: string
8
+ }
9
+
10
+ export function SnapshotCompare({ leftId: initialLeftId, rightId: initialRightId }: SnapshotCompareProps) {
11
+ const { snapshots } = useSnapshots()
12
+ const [leftId, setLeftId] = useState(initialLeftId || '')
13
+ const [rightId, setRightId] = useState(initialRightId || '')
14
+
15
+ const left = useMemo(() => snapshots.find(s => s.id === leftId), [snapshots, leftId])
16
+ const right = useMemo(() => snapshots.find(s => s.id === rightId), [snapshots, rightId])
17
+
18
+ if (snapshots.length < 2) {
19
+ return (
20
+ <div style={{
21
+ padding: '48px',
22
+ textAlign: 'center',
23
+ backgroundColor: 'var(--fd-card)',
24
+ borderRadius: '16px',
25
+ border: '1px solid var(--fd-border)',
26
+ }}>
27
+ <Icon name="camera" size={48} style={{ opacity: 0.3, marginBottom: '16px' }} />
28
+ <h2 style={{
29
+ margin: '0 0 8px',
30
+ fontSize: '18px',
31
+ fontWeight: 600,
32
+ color: 'var(--fd-foreground)',
33
+ }}>
34
+ Need at least 2 snapshots
35
+ </h2>
36
+ <p style={{
37
+ margin: 0,
38
+ fontSize: '14px',
39
+ color: 'var(--fd-muted-foreground)',
40
+ }}>
41
+ Capture snapshots from preview pages to compare them side by side.
42
+ </p>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ const selectStyle: React.CSSProperties = {
48
+ padding: '6px 10px',
49
+ fontSize: '12px',
50
+ border: '1px solid var(--fd-border)',
51
+ borderRadius: '6px',
52
+ backgroundColor: 'var(--fd-background)',
53
+ color: 'var(--fd-foreground)',
54
+ cursor: 'pointer',
55
+ maxWidth: '200px',
56
+ overflow: 'hidden',
57
+ textOverflow: 'ellipsis',
58
+ }
59
+
60
+ return (
61
+ <div style={{
62
+ display: 'flex',
63
+ flexDirection: 'column',
64
+ gap: '16px',
65
+ backgroundColor: 'var(--fd-card)',
66
+ borderRadius: '16px',
67
+ border: '1px solid var(--fd-border)',
68
+ overflow: 'hidden',
69
+ }}>
70
+ {/* Header with selectors */}
71
+ <div style={{
72
+ display: 'flex',
73
+ alignItems: 'center',
74
+ justifyContent: 'space-between',
75
+ padding: '16px 24px',
76
+ borderBottom: '1px solid var(--fd-border)',
77
+ background: 'linear-gradient(to bottom, var(--fd-card), var(--fd-muted))',
78
+ }}>
79
+ <h2 style={{
80
+ margin: 0,
81
+ fontSize: '18px',
82
+ fontWeight: 600,
83
+ color: 'var(--fd-foreground)',
84
+ letterSpacing: '-0.02em',
85
+ }}>
86
+ Compare Snapshots
87
+ </h2>
88
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
89
+ <select
90
+ value={leftId}
91
+ onChange={e => setLeftId(e.target.value)}
92
+ style={selectStyle}
93
+ >
94
+ <option value="">Select left...</option>
95
+ {snapshots.map(s => (
96
+ <option key={s.id} value={s.id}>
97
+ {s.label || s.previewName} — {s.stateOrStep}
98
+ </option>
99
+ ))}
100
+ </select>
101
+ <span style={{
102
+ fontSize: '12px',
103
+ fontWeight: 600,
104
+ color: 'var(--fd-muted-foreground)',
105
+ }}>
106
+ vs
107
+ </span>
108
+ <select
109
+ value={rightId}
110
+ onChange={e => setRightId(e.target.value)}
111
+ style={selectStyle}
112
+ >
113
+ <option value="">Select right...</option>
114
+ {snapshots.map(s => (
115
+ <option key={s.id} value={s.id}>
116
+ {s.label || s.previewName} — {s.stateOrStep}
117
+ </option>
118
+ ))}
119
+ </select>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Side-by-side comparison */}
124
+ <div style={{
125
+ display: 'grid',
126
+ gridTemplateColumns: '1fr 1fr',
127
+ gap: '1px',
128
+ backgroundColor: 'var(--fd-border)',
129
+ minHeight: '400px',
130
+ }}>
131
+ <ComparePane snapshot={left} label="Left" />
132
+ <ComparePane snapshot={right} label="Right" />
133
+ </div>
134
+
135
+ {/* Metadata comparison */}
136
+ {left && right && (
137
+ <div style={{
138
+ padding: '16px 24px',
139
+ borderTop: '1px solid var(--fd-border)',
140
+ display: 'grid',
141
+ gridTemplateColumns: '1fr 1fr',
142
+ gap: '24px',
143
+ fontSize: '12px',
144
+ }}>
145
+ <MetadataRow label="Preview" left={left.previewName} right={right.previewName} />
146
+ <MetadataRow label="State/Step" left={left.stateOrStep} right={right.stateOrStep} />
147
+ <MetadataRow label="Viewport" left={left.viewport} right={right.viewport} />
148
+ <MetadataRow label="Captured" left={new Date(left.createdAt).toLocaleString()} right={new Date(right.createdAt).toLocaleString()} />
149
+ </div>
150
+ )}
151
+ </div>
152
+ )
153
+ }
154
+
155
+ function ComparePane({ snapshot, label }: { snapshot?: { screenshotDataUrl: string; previewName: string }; label: string }) {
156
+ if (!snapshot) {
157
+ return (
158
+ <div style={{
159
+ backgroundColor: 'var(--fd-muted)',
160
+ display: 'flex',
161
+ alignItems: 'center',
162
+ justifyContent: 'center',
163
+ padding: '40px',
164
+ }}>
165
+ <span style={{
166
+ fontSize: '13px',
167
+ color: 'var(--fd-muted-foreground)',
168
+ }}>
169
+ Select {label.toLowerCase()} snapshot
170
+ </span>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ return (
176
+ <div style={{
177
+ backgroundColor: 'var(--fd-background)',
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ justifyContent: 'center',
181
+ padding: '16px',
182
+ overflow: 'auto',
183
+ }}>
184
+ <img
185
+ src={snapshot.screenshotDataUrl}
186
+ alt={`${label}: ${snapshot.previewName}`}
187
+ style={{
188
+ maxWidth: '100%',
189
+ maxHeight: '500px',
190
+ objectFit: 'contain',
191
+ borderRadius: '8px',
192
+ border: '1px solid var(--fd-border)',
193
+ }}
194
+ />
195
+ </div>
196
+ )
197
+ }
198
+
199
+ function MetadataRow({ label, left, right }: { label: string; left: string; right: string }) {
200
+ const changed = left !== right
201
+ return (
202
+ <>
203
+ <div>
204
+ <span style={{ fontWeight: 500, color: 'var(--fd-muted-foreground)' }}>{label}: </span>
205
+ <span style={{ color: 'var(--fd-foreground)' }}>{left}</span>
206
+ </div>
207
+ <div>
208
+ <span style={{ fontWeight: 500, color: 'var(--fd-muted-foreground)' }}>{label}: </span>
209
+ <span style={{
210
+ color: changed ? 'oklch(0.55 0.18 25)' : 'var(--fd-foreground)',
211
+ fontWeight: changed ? 600 : 400,
212
+ }}>{right}</span>
213
+ </div>
214
+ </>
215
+ )
216
+ }
@@ -0,0 +1,274 @@
1
+ import React from 'react'
2
+ import type { Snapshot } from '../types'
3
+ import { Icon } from '../icons'
4
+
5
+ interface SnapshotPanelProps {
6
+ snapshots: Snapshot[]
7
+ onDelete: (id: string) => void
8
+ onClose: () => void
9
+ }
10
+
11
+ export function SnapshotPanel({ snapshots, onDelete, onClose }: SnapshotPanelProps) {
12
+ return (
13
+ <>
14
+ {/* Backdrop */}
15
+ <div
16
+ onClick={onClose}
17
+ style={{
18
+ position: 'fixed',
19
+ inset: 0,
20
+ backgroundColor: 'rgba(0, 0, 0, 0.2)',
21
+ backdropFilter: 'blur(2px)',
22
+ zIndex: 90,
23
+ }}
24
+ />
25
+
26
+ {/* Panel */}
27
+ <div style={{
28
+ position: 'fixed',
29
+ top: 0,
30
+ right: 0,
31
+ bottom: 0,
32
+ width: '320px',
33
+ backgroundColor: 'var(--fd-card)',
34
+ borderLeft: '1px solid var(--fd-border)',
35
+ backdropFilter: 'blur(12px)',
36
+ zIndex: 91,
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ animation: 'snapshot-panel-slide 200ms ease-out',
40
+ }}>
41
+ {/* Header */}
42
+ <div style={{
43
+ display: 'flex',
44
+ alignItems: 'center',
45
+ justifyContent: 'space-between',
46
+ padding: '16px 20px',
47
+ borderBottom: '1px solid var(--fd-border)',
48
+ }}>
49
+ <h3 style={{
50
+ margin: 0,
51
+ fontSize: '14px',
52
+ fontWeight: 600,
53
+ color: 'var(--fd-foreground)',
54
+ letterSpacing: '-0.01em',
55
+ }}>
56
+ Snapshots
57
+ </h3>
58
+ <button
59
+ onClick={onClose}
60
+ style={{
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ justifyContent: 'center',
64
+ padding: '4px',
65
+ border: 'none',
66
+ borderRadius: '6px',
67
+ cursor: 'pointer',
68
+ backgroundColor: 'transparent',
69
+ color: 'var(--fd-muted-foreground)',
70
+ transition: 'all 0.1s ease',
71
+ }}
72
+ onMouseEnter={e => {
73
+ e.currentTarget.style.backgroundColor = 'var(--fd-muted)'
74
+ e.currentTarget.style.color = 'var(--fd-foreground)'
75
+ }}
76
+ onMouseLeave={e => {
77
+ e.currentTarget.style.backgroundColor = 'transparent'
78
+ e.currentTarget.style.color = 'var(--fd-muted-foreground)'
79
+ }}
80
+ title="Close"
81
+ >
82
+ <Icon name="x" size={16} />
83
+ </button>
84
+ </div>
85
+
86
+ {/* Content */}
87
+ <div style={{
88
+ flex: 1,
89
+ overflowY: 'auto',
90
+ padding: '16px',
91
+ }}>
92
+ {snapshots.length === 0 ? (
93
+ <div style={{
94
+ textAlign: 'center',
95
+ padding: '40px 20px',
96
+ color: 'var(--fd-muted-foreground)',
97
+ }}>
98
+ <Icon name="camera" size={32} style={{ opacity: 0.3, marginBottom: '12px' }} />
99
+ <p style={{ margin: 0, fontSize: '13px' }}>
100
+ No snapshots yet
101
+ </p>
102
+ <p style={{ margin: '4px 0 0', fontSize: '11px', opacity: 0.7 }}>
103
+ Use the camera button to capture
104
+ </p>
105
+ </div>
106
+ ) : (
107
+ <div style={{
108
+ display: 'grid',
109
+ gridTemplateColumns: '1fr',
110
+ gap: '12px',
111
+ }}>
112
+ {snapshots.map((snapshot, index) => (
113
+ <SnapshotCard
114
+ key={snapshot.id}
115
+ snapshot={snapshot}
116
+ onDelete={onDelete}
117
+ nextSnapshot={index + 1 < snapshots.length ? snapshots[index + 1] : undefined}
118
+ canCompare={snapshots.length >= 2}
119
+ />
120
+ ))}
121
+ </div>
122
+ )}
123
+ </div>
124
+ </div>
125
+
126
+ <style>{`
127
+ @keyframes snapshot-panel-slide {
128
+ from { transform: translateX(100%); }
129
+ to { transform: translateX(0); }
130
+ }
131
+ `}</style>
132
+ </>
133
+ )
134
+ }
135
+
136
+ interface SnapshotCardProps {
137
+ snapshot: Snapshot
138
+ onDelete: (id: string) => void
139
+ nextSnapshot?: Snapshot
140
+ canCompare: boolean
141
+ }
142
+
143
+ function SnapshotCard({ snapshot, onDelete, nextSnapshot, canCompare }: SnapshotCardProps) {
144
+ const timestamp = new Date(snapshot.createdAt)
145
+ const timeStr = timestamp.toLocaleString(undefined, {
146
+ month: 'short',
147
+ day: 'numeric',
148
+ hour: '2-digit',
149
+ minute: '2-digit',
150
+ })
151
+
152
+ return (
153
+ <div style={{
154
+ borderRadius: '10px',
155
+ border: '1px solid var(--fd-border)',
156
+ overflow: 'hidden',
157
+ backgroundColor: 'var(--fd-background)',
158
+ transition: 'box-shadow 0.15s ease',
159
+ }}>
160
+ {/* Thumbnail */}
161
+ <div style={{
162
+ position: 'relative',
163
+ backgroundColor: 'var(--fd-muted)',
164
+ aspectRatio: '16 / 10',
165
+ overflow: 'hidden',
166
+ }}>
167
+ <img
168
+ src={snapshot.screenshotDataUrl}
169
+ alt={`Snapshot of ${snapshot.previewName}`}
170
+ style={{
171
+ width: '100%',
172
+ height: '100%',
173
+ objectFit: 'cover',
174
+ display: 'block',
175
+ }}
176
+ />
177
+
178
+ {/* Delete button overlay */}
179
+ <button
180
+ onClick={() => onDelete(snapshot.id)}
181
+ style={{
182
+ position: 'absolute',
183
+ top: '6px',
184
+ right: '6px',
185
+ display: 'flex',
186
+ alignItems: 'center',
187
+ justifyContent: 'center',
188
+ width: '24px',
189
+ height: '24px',
190
+ border: 'none',
191
+ borderRadius: '6px',
192
+ cursor: 'pointer',
193
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
194
+ color: 'white',
195
+ opacity: 0.7,
196
+ transition: 'opacity 0.1s ease',
197
+ }}
198
+ onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
199
+ onMouseLeave={e => { e.currentTarget.style.opacity = '0.7' }}
200
+ title="Delete snapshot"
201
+ >
202
+ <Icon name="x" size={12} />
203
+ </button>
204
+
205
+ {/* Viewport badge */}
206
+ <span style={{
207
+ position: 'absolute',
208
+ bottom: '6px',
209
+ left: '6px',
210
+ padding: '2px 6px',
211
+ fontSize: '9px',
212
+ fontWeight: 600,
213
+ borderRadius: '4px',
214
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
215
+ color: 'white',
216
+ textTransform: 'uppercase',
217
+ letterSpacing: '0.04em',
218
+ }}>
219
+ {snapshot.viewport}
220
+ </span>
221
+ </div>
222
+
223
+ {/* Info */}
224
+ <div style={{ padding: '10px 12px' }}>
225
+ <div style={{
226
+ fontSize: '12px',
227
+ fontWeight: 600,
228
+ color: 'var(--fd-foreground)',
229
+ marginBottom: '2px',
230
+ overflow: 'hidden',
231
+ textOverflow: 'ellipsis',
232
+ whiteSpace: 'nowrap',
233
+ }}>
234
+ {snapshot.label || snapshot.previewName}
235
+ </div>
236
+
237
+ <div style={{
238
+ display: 'flex',
239
+ alignItems: 'center',
240
+ justifyContent: 'space-between',
241
+ gap: '8px',
242
+ }}>
243
+ <span style={{
244
+ fontSize: '10px',
245
+ color: 'var(--fd-muted-foreground)',
246
+ overflow: 'hidden',
247
+ textOverflow: 'ellipsis',
248
+ whiteSpace: 'nowrap',
249
+ }}>
250
+ {snapshot.stateOrStep} · {timeStr}
251
+ </span>
252
+
253
+ {canCompare && nextSnapshot && (
254
+ <a
255
+ href={`${(typeof window !== 'undefined' ? (import.meta.env?.BASE_URL ?? '/') : '/').replace(/\/$/, '')}/previews/_compare?left=${snapshot.id}&right=${nextSnapshot.id}`}
256
+ style={{
257
+ fontSize: '10px',
258
+ fontWeight: 500,
259
+ color: 'var(--fd-primary)',
260
+ textDecoration: 'none',
261
+ whiteSpace: 'nowrap',
262
+ transition: 'opacity 0.1s ease',
263
+ }}
264
+ onMouseEnter={e => { e.currentTarget.style.opacity = '0.7' }}
265
+ onMouseLeave={e => { e.currentTarget.style.opacity = '1' }}
266
+ >
267
+ Compare
268
+ </a>
269
+ )}
270
+ </div>
271
+ </div>
272
+ </div>
273
+ )
274
+ }
@@ -0,0 +1,66 @@
1
+ import React from 'react'
2
+ import type { ApprovalStatus } from '../types'
3
+
4
+ const STATUS_STYLES: Record<ApprovalStatus, { bg: string; color: string; border: string }> = {
5
+ draft: {
6
+ bg: 'oklch(0.94 0.06 85)',
7
+ color: 'oklch(0.45 0.12 85)',
8
+ border: 'oklch(0.88 0.08 85)',
9
+ },
10
+ 'in-review': {
11
+ bg: 'oklch(0.92 0.08 250)',
12
+ color: 'oklch(0.45 0.18 250)',
13
+ border: 'oklch(0.85 0.10 250)',
14
+ },
15
+ approved: {
16
+ bg: 'oklch(0.92 0.08 155)',
17
+ color: 'oklch(0.35 0.12 155)',
18
+ border: 'oklch(0.85 0.10 155)',
19
+ },
20
+ 'needs-changes': {
21
+ bg: 'oklch(0.92 0.08 25)',
22
+ color: 'oklch(0.45 0.15 25)',
23
+ border: 'oklch(0.85 0.12 25)',
24
+ },
25
+ }
26
+
27
+ const STATUS_LABELS: Record<ApprovalStatus, string> = {
28
+ draft: 'Draft',
29
+ 'in-review': 'In Review',
30
+ approved: 'Approved',
31
+ 'needs-changes': 'Needs Changes',
32
+ }
33
+
34
+ interface StatusBadgeProps {
35
+ status: ApprovalStatus
36
+ compact?: boolean
37
+ }
38
+
39
+ export function StatusBadge({ status, compact }: StatusBadgeProps) {
40
+ const style = STATUS_STYLES[status]
41
+ return (
42
+ <span style={{
43
+ display: 'inline-flex',
44
+ alignItems: 'center',
45
+ gap: '5px',
46
+ padding: compact ? '2px 6px' : '3px 10px',
47
+ fontSize: compact ? '10px' : '11px',
48
+ fontWeight: 600,
49
+ borderRadius: '6px',
50
+ border: `1px solid ${style.border}`,
51
+ backgroundColor: style.bg,
52
+ color: style.color,
53
+ textTransform: 'uppercase',
54
+ letterSpacing: '0.04em',
55
+ whiteSpace: 'nowrap',
56
+ }}>
57
+ <span style={{
58
+ width: compact ? '5px' : '6px',
59
+ height: compact ? '5px' : '6px',
60
+ borderRadius: '50%',
61
+ backgroundColor: style.color,
62
+ }} />
63
+ {STATUS_LABELS[status]}
64
+ </span>
65
+ )
66
+ }