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
@@ -0,0 +1,158 @@
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import type { ApprovalStatus, AuditLogEntry } from '../types'
3
+ import { StatusBadge } from './StatusBadge'
4
+
5
+ const ALL_STATUSES: ApprovalStatus[] = ['draft', 'in-review', 'approved', 'needs-changes']
6
+
7
+ interface StatusDropdownProps {
8
+ previewName: string
9
+ status: ApprovalStatus
10
+ onStatusChange: (status: ApprovalStatus) => void
11
+ getAuditLog: () => AuditLogEntry[]
12
+ }
13
+
14
+ export function StatusDropdown({ previewName, status, onStatusChange, getAuditLog }: StatusDropdownProps) {
15
+ const [isOpen, setIsOpen] = useState(false)
16
+ const [showLog, setShowLog] = useState(false)
17
+ const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
18
+ const ref = useRef<HTMLDivElement>(null)
19
+
20
+ // Close on outside click
21
+ useEffect(() => {
22
+ if (!isOpen) return
23
+ const handler = (e: MouseEvent) => {
24
+ if (ref.current && !ref.current.contains(e.target as Node)) {
25
+ setIsOpen(false)
26
+ setShowLog(false)
27
+ }
28
+ }
29
+ document.addEventListener('mousedown', handler)
30
+ return () => document.removeEventListener('mousedown', handler)
31
+ }, [isOpen])
32
+
33
+ const handleSelect = (s: ApprovalStatus) => {
34
+ if (s !== status) onStatusChange(s)
35
+ setIsOpen(false)
36
+ }
37
+
38
+ const handleToggleLog = () => {
39
+ if (!showLog) setAuditLog(getAuditLog())
40
+ setShowLog(!showLog)
41
+ }
42
+
43
+ return (
44
+ <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
45
+ <button
46
+ onClick={() => setIsOpen(!isOpen)}
47
+ style={{
48
+ background: 'none',
49
+ border: 'none',
50
+ cursor: 'pointer',
51
+ padding: 0,
52
+ }}
53
+ title={`Status: ${status}`}
54
+ >
55
+ <StatusBadge status={status} />
56
+ </button>
57
+
58
+ {isOpen && (
59
+ <div style={{
60
+ position: 'absolute',
61
+ top: '100%',
62
+ right: 0,
63
+ marginTop: '4px',
64
+ backgroundColor: 'var(--fd-card)',
65
+ borderRadius: '10px',
66
+ boxShadow: '0 8px 32px -8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.06)',
67
+ padding: '6px',
68
+ zIndex: 50,
69
+ minWidth: '180px',
70
+ }}>
71
+ {ALL_STATUSES.map(s => (
72
+ <button
73
+ key={s}
74
+ onClick={() => handleSelect(s)}
75
+ style={{
76
+ display: 'flex',
77
+ alignItems: 'center',
78
+ gap: '8px',
79
+ width: '100%',
80
+ padding: '8px 12px',
81
+ fontSize: '13px',
82
+ fontWeight: s === status ? 600 : 400,
83
+ border: 'none',
84
+ borderRadius: '6px',
85
+ cursor: 'pointer',
86
+ backgroundColor: s === status ? 'var(--fd-muted)' : 'transparent',
87
+ color: 'var(--fd-foreground)',
88
+ textAlign: 'left',
89
+ transition: 'background-color 0.1s',
90
+ }}
91
+ onMouseEnter={e => {
92
+ if (s !== status) e.currentTarget.style.backgroundColor = 'var(--fd-muted)'
93
+ }}
94
+ onMouseLeave={e => {
95
+ if (s !== status) e.currentTarget.style.backgroundColor = 'transparent'
96
+ }}
97
+ >
98
+ <StatusBadge status={s} compact />
99
+ </button>
100
+ ))}
101
+
102
+ <div style={{
103
+ borderTop: '1px solid var(--fd-border)',
104
+ marginTop: '4px',
105
+ paddingTop: '4px',
106
+ }}>
107
+ <button
108
+ onClick={handleToggleLog}
109
+ style={{
110
+ width: '100%',
111
+ padding: '6px 12px',
112
+ fontSize: '11px',
113
+ border: 'none',
114
+ borderRadius: '6px',
115
+ cursor: 'pointer',
116
+ backgroundColor: 'transparent',
117
+ color: 'var(--fd-muted-foreground)',
118
+ textAlign: 'left',
119
+ }}
120
+ >
121
+ {showLog ? 'Hide' : 'Show'} History
122
+ </button>
123
+
124
+ {showLog && auditLog.length > 0 && (
125
+ <div style={{
126
+ maxHeight: '150px',
127
+ overflowY: 'auto',
128
+ padding: '4px 8px',
129
+ }}>
130
+ {auditLog.map((entry, i) => (
131
+ <div key={i} style={{
132
+ fontSize: '10px',
133
+ color: 'var(--fd-muted-foreground)',
134
+ padding: '3px 0',
135
+ borderBottom: i < auditLog.length - 1 ? '1px solid var(--fd-border)' : 'none',
136
+ }}>
137
+ <span style={{ fontWeight: 500 }}>{entry.changedBy}</span>
138
+ {' '}changed{' '}
139
+ <span style={{ textDecoration: 'line-through' }}>{entry.from}</span>
140
+ {' → '}{entry.to}
141
+ <div style={{ fontSize: '9px', opacity: 0.7 }}>
142
+ {new Date(entry.changedAt).toLocaleString()}
143
+ </div>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ )}
148
+ {showLog && auditLog.length === 0 && (
149
+ <div style={{ padding: '4px 8px', fontSize: '10px', color: 'var(--fd-muted-foreground)' }}>
150
+ No history yet
151
+ </div>
152
+ )}
153
+ </div>
154
+ </div>
155
+ )}
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,438 @@
1
+ import React, { useState } from 'react'
2
+ import { Icon } from '../icons'
3
+ import type { TokenOverride } from '../types'
4
+
5
+ interface TokenPlaygroundProps {
6
+ tokens: Record<string, any>
7
+ overrides: TokenOverride[]
8
+ onSetOverride: (category: string, name: string, originalValue: string, newValue: string) => void
9
+ onRemoveOverride: (category: string, name: string) => void
10
+ onResetAll: () => void
11
+ onClose: () => void
12
+ }
13
+
14
+ // --- helpers ---
15
+
16
+ function findOverride(overrides: TokenOverride[], category: string, name: string): TokenOverride | undefined {
17
+ return overrides.find(o => o.category === category && o.name === name)
18
+ }
19
+
20
+ function parseNumeric(value: string | number): number {
21
+ const n = parseFloat(String(value))
22
+ return Number.isNaN(n) ? 0 : n
23
+ }
24
+
25
+ // --- sub-components ---
26
+
27
+ function ColorEditor({
28
+ category, name, value, override, onSet, onRemove,
29
+ }: {
30
+ category: string; name: string; value: string
31
+ override: TokenOverride | undefined
32
+ onSet: (cat: string, name: string, orig: string, val: string) => void
33
+ onRemove: (cat: string, name: string) => void
34
+ }) {
35
+ const current = override?.overrideValue ?? value
36
+ return (
37
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
38
+ <input
39
+ type="color"
40
+ value={current}
41
+ onChange={e => onSet(category, name, value, e.target.value)}
42
+ style={{ width: '32px', height: '28px', border: 'none', padding: 0, cursor: 'pointer', background: 'none' }}
43
+ />
44
+ <span style={monoStyle}>{current}</span>
45
+ {override && <ResetButton onClick={() => onRemove(category, name)} />}
46
+ </div>
47
+ )
48
+ }
49
+
50
+ function RangeEditor({
51
+ category, name, value, override, onSet, onRemove, max = 100, unit = 'px',
52
+ }: {
53
+ category: string; name: string; value: string
54
+ override: TokenOverride | undefined
55
+ onSet: (cat: string, name: string, orig: string, val: string) => void
56
+ onRemove: (cat: string, name: string) => void
57
+ max?: number; unit?: string
58
+ }) {
59
+ const current = override?.overrideValue ?? value
60
+ const num = parseNumeric(current)
61
+ return (
62
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
63
+ <input
64
+ type="range"
65
+ min={0}
66
+ max={max}
67
+ value={num}
68
+ onChange={e => onSet(category, name, value, `${e.target.value}${unit}`)}
69
+ style={{ width: '100px', accentColor: 'oklch(0.65 0.15 250)' }}
70
+ />
71
+ <span style={monoStyle}>{current}</span>
72
+ {override && <ResetButton onClick={() => onRemove(category, name)} />}
73
+ </div>
74
+ )
75
+ }
76
+
77
+ function NumberEditor({
78
+ category, name, value, override, onSet, onRemove, unit = 'px',
79
+ }: {
80
+ category: string; name: string; value: string | number
81
+ override: TokenOverride | undefined
82
+ onSet: (cat: string, name: string, orig: string, val: string) => void
83
+ onRemove: (cat: string, name: string) => void
84
+ unit?: string
85
+ }) {
86
+ const current = override?.overrideValue ?? String(value)
87
+ const num = parseNumeric(current)
88
+ return (
89
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
90
+ <input
91
+ type="number"
92
+ min={0}
93
+ value={num}
94
+ onChange={e => onSet(category, name, String(value), `${e.target.value}${unit}`)}
95
+ style={{
96
+ width: '72px',
97
+ padding: '4px 6px',
98
+ fontSize: '12px',
99
+ fontFamily: 'monospace',
100
+ border: '1px solid var(--fd-border)',
101
+ borderRadius: '4px',
102
+ background: 'var(--fd-background)',
103
+ color: 'var(--fd-foreground)',
104
+ }}
105
+ />
106
+ <span style={monoStyle}>{current}</span>
107
+ {override && <ResetButton onClick={() => onRemove(category, name)} />}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ function ReadOnlyValue({ value, category, name, override, onRemove }: {
113
+ value: string | number
114
+ category: string; name: string
115
+ override: TokenOverride | undefined
116
+ onRemove: (cat: string, name: string) => void
117
+ }) {
118
+ return (
119
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
120
+ <span style={monoStyle}>{override?.overrideValue ?? String(value)}</span>
121
+ {override && <ResetButton onClick={() => onRemove(category, name)} />}
122
+ </div>
123
+ )
124
+ }
125
+
126
+ function ResetButton({ onClick }: { onClick: () => void }) {
127
+ return (
128
+ <button
129
+ onClick={onClick}
130
+ title="Reset to original"
131
+ style={{
132
+ display: 'flex',
133
+ alignItems: 'center',
134
+ justifyContent: 'center',
135
+ width: '22px',
136
+ height: '22px',
137
+ border: '1px solid var(--fd-border)',
138
+ borderRadius: '4px',
139
+ background: 'none',
140
+ cursor: 'pointer',
141
+ color: 'var(--fd-muted-foreground)',
142
+ flexShrink: 0,
143
+ }}
144
+ >
145
+ <Icon name="x" size={12} />
146
+ </button>
147
+ )
148
+ }
149
+
150
+ // --- category section ---
151
+
152
+ function CategorySection({
153
+ title,
154
+ expanded,
155
+ onToggle,
156
+ children,
157
+ }: {
158
+ title: string
159
+ expanded: boolean
160
+ onToggle: () => void
161
+ children: React.ReactNode
162
+ }) {
163
+ return (
164
+ <div style={{ borderBottom: '1px solid var(--fd-border)' }}>
165
+ <button
166
+ onClick={onToggle}
167
+ style={{
168
+ display: 'flex',
169
+ alignItems: 'center',
170
+ justifyContent: 'space-between',
171
+ width: '100%',
172
+ padding: '10px 16px',
173
+ border: 'none',
174
+ background: 'none',
175
+ cursor: 'pointer',
176
+ color: 'var(--fd-foreground)',
177
+ fontSize: '13px',
178
+ fontWeight: 600,
179
+ textAlign: 'left',
180
+ }}
181
+ >
182
+ {title}
183
+ <Icon
184
+ name="chevron-right"
185
+ size={14}
186
+ style={{
187
+ transition: 'transform 0.15s',
188
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
189
+ }}
190
+ />
191
+ </button>
192
+ {expanded && <div>{children}</div>}
193
+ </div>
194
+ )
195
+ }
196
+
197
+ function TokenRow({ label, children }: { label: string; children: React.ReactNode }) {
198
+ return (
199
+ <div style={{
200
+ display: 'flex',
201
+ alignItems: 'center',
202
+ justifyContent: 'space-between',
203
+ padding: '6px 16px 6px 28px',
204
+ gap: '8px',
205
+ borderTop: '1px solid color-mix(in oklch, var(--fd-border) 40%, transparent)',
206
+ }}>
207
+ <span style={{
208
+ fontSize: '12px',
209
+ color: 'var(--fd-muted-foreground)',
210
+ whiteSpace: 'nowrap',
211
+ overflow: 'hidden',
212
+ textOverflow: 'ellipsis',
213
+ maxWidth: '100px',
214
+ }}>
215
+ {label}
216
+ </span>
217
+ <div style={{ flexShrink: 0 }}>
218
+ {children}
219
+ </div>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ // --- main ---
225
+
226
+ const monoStyle: React.CSSProperties = {
227
+ fontSize: '11px',
228
+ fontFamily: 'monospace',
229
+ color: 'var(--fd-muted-foreground)',
230
+ whiteSpace: 'nowrap',
231
+ }
232
+
233
+ export function TokenPlayground({
234
+ tokens,
235
+ overrides,
236
+ onSetOverride,
237
+ onRemoveOverride,
238
+ onResetAll,
239
+ onClose,
240
+ }: TokenPlaygroundProps) {
241
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({
242
+ colors: true,
243
+ spacing: false,
244
+ radius: false,
245
+ shadows: false,
246
+ typography: false,
247
+ })
248
+
249
+ const toggle = (key: string) =>
250
+ setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
251
+
252
+ const renderEntries = (
253
+ category: string,
254
+ entries: Record<string, string | number>,
255
+ editor: 'color' | 'range' | 'number' | 'readonly',
256
+ editorProps?: { max?: number; unit?: string },
257
+ ) =>
258
+ Object.entries(entries).map(([name, value]) => {
259
+ const ov = findOverride(overrides, category, name)
260
+ return (
261
+ <TokenRow key={name} label={name}>
262
+ {editor === 'color' && (
263
+ <ColorEditor
264
+ category={category} name={name} value={String(value)}
265
+ override={ov} onSet={onSetOverride} onRemove={onRemoveOverride}
266
+ />
267
+ )}
268
+ {editor === 'range' && (
269
+ <RangeEditor
270
+ category={category} name={name} value={String(value)}
271
+ override={ov} onSet={onSetOverride} onRemove={onRemoveOverride}
272
+ max={editorProps?.max} unit={editorProps?.unit}
273
+ />
274
+ )}
275
+ {editor === 'number' && (
276
+ <NumberEditor
277
+ category={category} name={name} value={value}
278
+ override={ov} onSet={onSetOverride} onRemove={onRemoveOverride}
279
+ unit={editorProps?.unit}
280
+ />
281
+ )}
282
+ {editor === 'readonly' && (
283
+ <ReadOnlyValue
284
+ value={value} category={category} name={name}
285
+ override={ov} onRemove={onRemoveOverride}
286
+ />
287
+ )}
288
+ </TokenRow>
289
+ )
290
+ })
291
+
292
+ return (
293
+ <div style={{
294
+ position: 'fixed',
295
+ top: 0,
296
+ right: 0,
297
+ width: '360px',
298
+ height: '100vh',
299
+ display: 'flex',
300
+ flexDirection: 'column',
301
+ background: 'var(--fd-background)',
302
+ borderLeft: '1px solid var(--fd-border)',
303
+ boxShadow: '-4px 0 24px oklch(0 0 0 / 0.12)',
304
+ zIndex: 9999,
305
+ fontFamily: 'system-ui, -apple-system, sans-serif',
306
+ }}>
307
+ {/* Header */}
308
+ <div style={{
309
+ display: 'flex',
310
+ alignItems: 'center',
311
+ justifyContent: 'space-between',
312
+ padding: '12px 16px',
313
+ borderBottom: '1px solid var(--fd-border)',
314
+ flexShrink: 0,
315
+ }}>
316
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
317
+ <Icon name="palette" size={18} style={{ color: 'oklch(0.65 0.15 250)' }} />
318
+ <span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--fd-foreground)' }}>
319
+ Token Playground
320
+ </span>
321
+ </div>
322
+ <button
323
+ onClick={onClose}
324
+ title="Close panel"
325
+ style={{
326
+ display: 'flex',
327
+ alignItems: 'center',
328
+ justifyContent: 'center',
329
+ width: '28px',
330
+ height: '28px',
331
+ border: '1px solid var(--fd-border)',
332
+ borderRadius: '6px',
333
+ background: 'none',
334
+ cursor: 'pointer',
335
+ color: 'var(--fd-muted-foreground)',
336
+ }}
337
+ >
338
+ <Icon name="x" size={16} />
339
+ </button>
340
+ </div>
341
+
342
+ {/* Scrollable body */}
343
+ <div style={{ flex: 1, overflowY: 'auto' }}>
344
+ {/* Colors */}
345
+ {tokens.colors && (
346
+ <CategorySection title="Colors" expanded={!!expanded.colors} onToggle={() => toggle('colors')}>
347
+ {renderEntries('colors', tokens.colors, 'color')}
348
+ </CategorySection>
349
+ )}
350
+
351
+ {/* Spacing */}
352
+ {tokens.spacing && (
353
+ <CategorySection title="Spacing" expanded={!!expanded.spacing} onToggle={() => toggle('spacing')}>
354
+ {renderEntries('spacing', tokens.spacing, 'range', { max: 100, unit: 'px' })}
355
+ </CategorySection>
356
+ )}
357
+
358
+ {/* Radius */}
359
+ {tokens.radius && (
360
+ <CategorySection title="Radius" expanded={!!expanded.radius} onToggle={() => toggle('radius')}>
361
+ {renderEntries('radius', tokens.radius, 'range', { max: 100, unit: 'px' })}
362
+ </CategorySection>
363
+ )}
364
+
365
+ {/* Shadows */}
366
+ {tokens.shadows && (
367
+ <CategorySection title="Shadows" expanded={!!expanded.shadows} onToggle={() => toggle('shadows')}>
368
+ {renderEntries('shadows', tokens.shadows, 'readonly')}
369
+ </CategorySection>
370
+ )}
371
+
372
+ {/* Typography */}
373
+ {tokens.typography && (
374
+ <CategorySection title="Typography" expanded={!!expanded.typography} onToggle={() => toggle('typography')}>
375
+ {tokens.typography.sizes && (
376
+ <>
377
+ <div style={{
378
+ padding: '6px 28px',
379
+ fontSize: '11px',
380
+ fontWeight: 500,
381
+ color: 'var(--fd-muted-foreground)',
382
+ background: 'var(--fd-muted)',
383
+ textTransform: 'uppercase',
384
+ letterSpacing: '0.05em',
385
+ }}>
386
+ Sizes
387
+ </div>
388
+ {renderEntries('typography-sizes', tokens.typography.sizes, 'number', { unit: 'px' })}
389
+ </>
390
+ )}
391
+ {tokens.typography.weights && (
392
+ <>
393
+ <div style={{
394
+ padding: '6px 28px',
395
+ fontSize: '11px',
396
+ fontWeight: 500,
397
+ color: 'var(--fd-muted-foreground)',
398
+ background: 'var(--fd-muted)',
399
+ textTransform: 'uppercase',
400
+ letterSpacing: '0.05em',
401
+ }}>
402
+ Weights
403
+ </div>
404
+ {renderEntries('typography-weights', tokens.typography.weights, 'readonly')}
405
+ </>
406
+ )}
407
+ </CategorySection>
408
+ )}
409
+ </div>
410
+
411
+ {/* Footer */}
412
+ <div style={{
413
+ padding: '12px 16px',
414
+ borderTop: '1px solid var(--fd-border)',
415
+ flexShrink: 0,
416
+ }}>
417
+ <button
418
+ onClick={onResetAll}
419
+ disabled={overrides.length === 0}
420
+ style={{
421
+ width: '100%',
422
+ padding: '8px 0',
423
+ fontSize: '13px',
424
+ fontWeight: 500,
425
+ border: '1px solid var(--fd-border)',
426
+ borderRadius: '6px',
427
+ background: overrides.length > 0 ? 'oklch(0.55 0.15 25)' : 'var(--fd-muted)',
428
+ color: overrides.length > 0 ? 'white' : 'var(--fd-muted-foreground)',
429
+ cursor: overrides.length > 0 ? 'pointer' : 'default',
430
+ transition: 'background 0.15s, color 0.15s',
431
+ }}
432
+ >
433
+ Reset All ({overrides.length} override{overrides.length !== 1 ? 's' : ''})
434
+ </button>
435
+ </div>
436
+ </div>
437
+ )
438
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react'
2
+ import type { Viewport } from '../hooks/useViewport'
3
+ import { VIEWPORT_WIDTHS } from '../hooks/useViewport'
4
+ import { Icon } from '../icons'
5
+
6
+ interface ViewportControlsProps {
7
+ viewport: Viewport
8
+ onViewportChange: (v: Viewport) => void
9
+ }
10
+
11
+ const viewportMeta: Record<Viewport, { label: string; icon: 'mobile' | 'tablet' | 'desktop' }> = {
12
+ mobile: { label: 'Mobile', icon: 'mobile' },
13
+ tablet: { label: 'Tablet', icon: 'tablet' },
14
+ desktop: { label: 'Desktop', icon: 'desktop' },
15
+ }
16
+
17
+ export function ViewportControls({ viewport, onViewportChange }: ViewportControlsProps) {
18
+ return (
19
+ <div style={{
20
+ display: 'flex',
21
+ alignItems: 'center',
22
+ gap: '2px',
23
+ }}>
24
+ {(Object.keys(viewportMeta) as Viewport[]).map((key) => {
25
+ const { label, icon } = viewportMeta[key]
26
+ const width = VIEWPORT_WIDTHS[key]
27
+ const isActive = viewport === key
28
+ return (
29
+ <button
30
+ key={key}
31
+ onClick={() => onViewportChange(key)}
32
+ style={{
33
+ padding: '6px 12px',
34
+ fontSize: '12px',
35
+ fontWeight: 500,
36
+ border: 'none',
37
+ borderRadius: '8px',
38
+ cursor: 'pointer',
39
+ backgroundColor: isActive ? 'var(--fd-primary)' : 'var(--fd-muted)',
40
+ color: isActive ? 'var(--fd-primary-foreground)' : 'var(--fd-muted-foreground)',
41
+ transition: 'all 0.15s ease',
42
+ display: 'flex',
43
+ alignItems: 'center',
44
+ gap: '5px',
45
+ }}
46
+ onMouseEnter={(e) => {
47
+ if (!isActive) {
48
+ e.currentTarget.style.backgroundColor = 'var(--fd-secondary)'
49
+ e.currentTarget.style.color = 'var(--fd-foreground)'
50
+ }
51
+ }}
52
+ onMouseLeave={(e) => {
53
+ if (!isActive) {
54
+ e.currentTarget.style.backgroundColor = 'var(--fd-muted)'
55
+ e.currentTarget.style.color = 'var(--fd-muted-foreground)'
56
+ }
57
+ }}
58
+ title={`${label} (${width}px)`}
59
+ >
60
+ <Icon name={icon} size={14} />
61
+ <span style={{ fontSize: '10px', opacity: 0.7 }}>{width}</span>
62
+ </button>
63
+ )
64
+ })}
65
+ </div>
66
+ )
67
+ }