@multiplatform.one/theme 6.0.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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +201 -0
- package/package.json +85 -0
- package/src/audit/baselines/home-login.json +171 -0
- package/src/audit/baselines/home-login.png +0 -0
- package/src/audit/baselines/home-pokemon.json +355 -0
- package/src/audit/baselines/home-pokemon.png +0 -0
- package/src/audit/baselines/home.json +194 -0
- package/src/audit/baselines/home.png +0 -0
- package/src/audit/baselines/pokemon.json +251 -0
- package/src/audit/baselines/pokemon.png +0 -0
- package/src/audit/constraintAudit.ts +874 -0
- package/src/audit/diffReport.spec.ts +191 -0
- package/src/audit/diffReport.ts +344 -0
- package/src/audit/index.ts +10 -0
- package/src/font.ts +49 -0
- package/src/fonts/createFontLoader.ts +15 -0
- package/src/fonts/createFontLoader.web.ts +9 -0
- package/src/fonts/index.ts +2 -0
- package/src/fonts/types.ts +13 -0
- package/src/index.ts +3 -0
- package/src/theme/Intent.tsx +30 -0
- package/src/theme/Preset.tsx +93 -0
- package/src/theme/PresetContext.ts +27 -0
- package/src/theme/Tint.tsx +44 -0
- package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +69 -0
- package/src/theme/animations/css.ts +25 -0
- package/src/theme/animations/index.ts +1 -0
- package/src/theme/animations/index.web.ts +1 -0
- package/src/theme/animations/reactNative.ts +54 -0
- package/src/theme/colorRules.spec.ts +195 -0
- package/src/theme/colorRules.ts +166 -0
- package/src/theme/cookies.spec.ts +185 -0
- package/src/theme/cookies.ts +209 -0
- package/src/theme/createDefaultThemeConfig.ts +86 -0
- package/src/theme/createThemes.ts +286 -0
- package/src/theme/devtools/ColorLineVisualizer.tsx +261 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +1657 -0
- package/src/theme/devtools/buildSnapshot.spec.ts +113 -0
- package/src/theme/devtools/buildSnapshot.ts +103 -0
- package/src/theme/devtools/knobIcons.tsx +217 -0
- package/src/theme/devtools/vizConstants.spec.ts +58 -0
- package/src/theme/devtools/vizConstants.ts +28 -0
- package/src/theme/index.ts +21 -0
- package/src/theme/intent.spec.tsx +276 -0
- package/src/theme/intents.ts +22 -0
- package/src/theme/knobs.ts +147 -0
- package/src/theme/pokemonScreen.spec.ts +109 -0
- package/src/theme/preset.spec.tsx +242 -0
- package/src/theme/preset.types.ts +22 -0
- package/src/theme/presets.spec.ts +252 -0
- package/src/theme/presets.ts +49 -0
- package/src/theme/recipes.ts +69 -0
- package/src/theme/resolveKnobs.spec.ts +571 -0
- package/src/theme/resolveKnobs.ts +226 -0
- package/src/theme/shared.spec.ts +84 -0
- package/src/theme/shared.tsx +131 -0
- package/src/theme/theme.native.tsx +65 -0
- package/src/theme/theme.tsx +190 -0
- package/src/theme/tint.spec.tsx +179 -0
- package/src/theme/useColorScale.native.ts +7 -0
- package/src/theme/useColorScale.ts +29 -0
- package/src/theme/useResolvedKnobs.ts +121 -0
- package/src/theme/useTheme.native.ts +49 -0
- package/src/theme/useTheme.ts +213 -0
- package/tsconfig.json +11 -0
- package/vitest.config.mjs +9 -0
|
@@ -0,0 +1,1657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack Devtools plugin panel for controlling theme knobs at runtime.
|
|
3
|
+
* TODO(native): This panel is web-only for now. TanStack Devtools does not yet
|
|
4
|
+
* support React Native; when it does, revisit this panel for native compatibility.
|
|
5
|
+
*
|
|
6
|
+
* Controls:
|
|
7
|
+
* - Color scheme (system / light / dark) via @vxrn/color-scheme
|
|
8
|
+
* - Preset picker (using registered presets)
|
|
9
|
+
* - All knobs: fillStyle, borderRadius, borderWidth, elevation, space, size,
|
|
10
|
+
* textAccent, headingFont, bodyFont, fontWeight, animation
|
|
11
|
+
*
|
|
12
|
+
* This panel is rendered *inside* the TanStack Devtools shell so it shares
|
|
13
|
+
* the same React tree and can call hooks like `useTheme` / `useUserScheme`.
|
|
14
|
+
*
|
|
15
|
+
* ---------------------------------------------------------------------------
|
|
16
|
+
* Theme sync (devtools panel ↔ website)
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
* The TanStack Devtools shell has its own "Choose theme" setting (light/dark)
|
|
19
|
+
* stored in shared storage under `tanstack_devtools_settings`. This panel keeps
|
|
20
|
+
* the devtools chrome theme and the website color scheme in sync:
|
|
21
|
+
*
|
|
22
|
+
* - Devtools → Website: When the user changes the devtools panel theme, the
|
|
23
|
+
* `devtoolsTheme` prop (passed via the render function callback) updates.
|
|
24
|
+
* A `useEffect` detects the change and calls `scheme.set()`.
|
|
25
|
+
*
|
|
26
|
+
* - Website → Devtools: When the user changes the color scheme via this
|
|
27
|
+
* panel's controls, a `useEffect` writes the resolved scheme value to the
|
|
28
|
+
* `tanstack_devtools_settings` storage entry. The devtools chrome
|
|
29
|
+
* picks this up on its next initialization (page reload).
|
|
30
|
+
*
|
|
31
|
+
* ---------------------------------------------------------------------------
|
|
32
|
+
* Cookie / SSR verification
|
|
33
|
+
* ---------------------------------------------------------------------------
|
|
34
|
+
* 1. On every control change, the panel calls `setKnobs(...)` from `useTheme`.
|
|
35
|
+
* 2. `setKnobs` in theme.tsx automatically writes the updated knobs to
|
|
36
|
+
* `mp.preset` + `mp.ov.*` cookies.
|
|
37
|
+
* 3. On page refresh, SSR reads the cookie from request headers and renders
|
|
38
|
+
* with the persisted theme — no flash, no hydration mismatch.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
42
|
+
import { writeToClipboard } from "@multiplatform.one/platform";
|
|
43
|
+
import { storage } from "@multiplatform.one/store";
|
|
44
|
+
import { useTheme } from "../useTheme";
|
|
45
|
+
import {
|
|
46
|
+
type Knobs,
|
|
47
|
+
type InteractionState,
|
|
48
|
+
defaultKnobs,
|
|
49
|
+
FillStyle,
|
|
50
|
+
BorderRadius,
|
|
51
|
+
BorderWidth,
|
|
52
|
+
Elevation,
|
|
53
|
+
Space,
|
|
54
|
+
Size,
|
|
55
|
+
TextAccent,
|
|
56
|
+
HeadingFont,
|
|
57
|
+
BodyFont,
|
|
58
|
+
FontWeight,
|
|
59
|
+
interactionStates,
|
|
60
|
+
} from "../knobs";
|
|
61
|
+
import { animationNames } from "../animations/index";
|
|
62
|
+
import { getPresetNames, getPreset } from "../shared";
|
|
63
|
+
import { ColorLineVisualizer } from "./ColorLineVisualizer";
|
|
64
|
+
import { buildSnapshot } from "./buildSnapshot";
|
|
65
|
+
import {
|
|
66
|
+
fillIcon as _fillIcon,
|
|
67
|
+
spaceIcon as _spaceIcon,
|
|
68
|
+
sizeIcon as _sizeIcon,
|
|
69
|
+
borderRadiusIcon as _borderRadiusIcon,
|
|
70
|
+
borderWidthIcon as _borderWidthIcon,
|
|
71
|
+
elevationIcon as _elevationIcon,
|
|
72
|
+
textAccentIcon as _textAccentIcon,
|
|
73
|
+
headingFontIcon as _headingFontIcon,
|
|
74
|
+
bodyFontIcon as _bodyFontIcon,
|
|
75
|
+
fontWeightIcon as _fontWeightIcon,
|
|
76
|
+
modeIcon as _modeIcon,
|
|
77
|
+
} from "./knobIcons";
|
|
78
|
+
|
|
79
|
+
function extractAnimationNames(tamaguiConfig: unknown): string[] {
|
|
80
|
+
const config = tamaguiConfig as Record<string, unknown> | null;
|
|
81
|
+
const anims = config?.animations as Record<string, unknown> | undefined;
|
|
82
|
+
if (anims) {
|
|
83
|
+
const keys = Object.keys(anims).filter((key) => key !== "animations" && !key.startsWith("_"));
|
|
84
|
+
if (keys.length > 0) return keys;
|
|
85
|
+
}
|
|
86
|
+
return animationNames as string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Constants
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Storage key used by TanStack Devtools for its own settings. */
|
|
94
|
+
const tanstackDevtoolsSettings = "tanstack_devtools_settings";
|
|
95
|
+
|
|
96
|
+
/** Storage key for persisted pinned knobs. */
|
|
97
|
+
const pinnedKnobsStorageKey = "ts-theme-pinned";
|
|
98
|
+
|
|
99
|
+
/** Storage key for persisted expanded knob groups (those showing state sub-rows). */
|
|
100
|
+
const expandedKnobsStorageKey = "ts-theme-expanded";
|
|
101
|
+
|
|
102
|
+
/** Human-readable labels for interaction states in the state sub-rows. */
|
|
103
|
+
const stateDisplayLabels: Record<InteractionState, string> = {
|
|
104
|
+
hover: "hover",
|
|
105
|
+
press: "press",
|
|
106
|
+
focus: "focus",
|
|
107
|
+
focusVisible: "focusV",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** All knob property keys for override tracking. */
|
|
111
|
+
const knobKeys: (keyof Knobs)[] = [
|
|
112
|
+
"fillStyle",
|
|
113
|
+
"borderRadius",
|
|
114
|
+
"borderWidth",
|
|
115
|
+
"elevation",
|
|
116
|
+
"space",
|
|
117
|
+
"size",
|
|
118
|
+
"textAccent",
|
|
119
|
+
"headingFont",
|
|
120
|
+
"bodyFont",
|
|
121
|
+
"fontWeight",
|
|
122
|
+
"animation",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/** Knob keys that support per-state overrides. */
|
|
126
|
+
const stateKnobKeys: (keyof Knobs)[] = [
|
|
127
|
+
"fillStyle",
|
|
128
|
+
"borderRadius",
|
|
129
|
+
"borderWidth",
|
|
130
|
+
"elevation",
|
|
131
|
+
"space",
|
|
132
|
+
"textAccent",
|
|
133
|
+
"headingFont",
|
|
134
|
+
"bodyFont",
|
|
135
|
+
"fontWeight",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
interface DesignProfile {
|
|
139
|
+
[key: string]: string | undefined;
|
|
140
|
+
color?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const designProfiles: Record<string, DesignProfile> = {
|
|
144
|
+
Bootstrap: {
|
|
145
|
+
fillStyle: "filled",
|
|
146
|
+
borderRadius: "small",
|
|
147
|
+
borderWidth: "medium",
|
|
148
|
+
elevation: "none",
|
|
149
|
+
space: "medium",
|
|
150
|
+
size: "medium",
|
|
151
|
+
textAccent: "high",
|
|
152
|
+
headingFont: "sans-serif",
|
|
153
|
+
bodyFont: "sans-serif",
|
|
154
|
+
fontWeight: "regular",
|
|
155
|
+
animation: "quick",
|
|
156
|
+
color: "blue",
|
|
157
|
+
},
|
|
158
|
+
"Material UI": {
|
|
159
|
+
fillStyle: "filled",
|
|
160
|
+
borderRadius: "large",
|
|
161
|
+
borderWidth: "none",
|
|
162
|
+
elevation: "small",
|
|
163
|
+
space: "medium",
|
|
164
|
+
size: "medium",
|
|
165
|
+
textAccent: "high",
|
|
166
|
+
headingFont: "sans-serif",
|
|
167
|
+
bodyFont: "sans-serif",
|
|
168
|
+
fontWeight: "bold",
|
|
169
|
+
animation: "medium",
|
|
170
|
+
color: "purple",
|
|
171
|
+
"hover.elevation": "medium",
|
|
172
|
+
},
|
|
173
|
+
"GTK4 / Adwaita": {
|
|
174
|
+
fillStyle: "filled",
|
|
175
|
+
borderRadius: "small",
|
|
176
|
+
borderWidth: "small",
|
|
177
|
+
elevation: "none",
|
|
178
|
+
space: "medium",
|
|
179
|
+
size: "medium",
|
|
180
|
+
textAccent: "high",
|
|
181
|
+
headingFont: "sans-serif",
|
|
182
|
+
bodyFont: "sans-serif",
|
|
183
|
+
fontWeight: "regular",
|
|
184
|
+
animation: "quick",
|
|
185
|
+
color: "gray",
|
|
186
|
+
},
|
|
187
|
+
"Fluent UI": {
|
|
188
|
+
fillStyle: "filled",
|
|
189
|
+
borderRadius: "small",
|
|
190
|
+
borderWidth: "small",
|
|
191
|
+
elevation: "small",
|
|
192
|
+
space: "medium",
|
|
193
|
+
size: "medium",
|
|
194
|
+
textAccent: "high",
|
|
195
|
+
headingFont: "sans-serif",
|
|
196
|
+
bodyFont: "sans-serif",
|
|
197
|
+
fontWeight: "regular",
|
|
198
|
+
animation: "quick",
|
|
199
|
+
color: "blue",
|
|
200
|
+
"hover.elevation": "small",
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const profileNames = ["", ...Object.keys(designProfiles)] as const;
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Helpers
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
type SchemeHook = {
|
|
211
|
+
setting: "system" | "light" | "dark";
|
|
212
|
+
value: "light" | "dark";
|
|
213
|
+
set: (s: "system" | "light" | "dark") => void;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Inline styles that adapt to the devtools chrome theme (light/dark) so the
|
|
218
|
+
* panel looks correct regardless of the devtools panel theme setting.
|
|
219
|
+
*/
|
|
220
|
+
function createStyles(isDark: boolean) {
|
|
221
|
+
return {
|
|
222
|
+
panel: {
|
|
223
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
224
|
+
fontSize: 13,
|
|
225
|
+
color: isDark ? "#e8e8e8" : "#1a1a1a",
|
|
226
|
+
padding: 12,
|
|
227
|
+
display: "flex" as const,
|
|
228
|
+
flexDirection: "column" as const,
|
|
229
|
+
gap: 12,
|
|
230
|
+
height: "100%",
|
|
231
|
+
overflowY: "auto" as const,
|
|
232
|
+
background: "transparent",
|
|
233
|
+
},
|
|
234
|
+
knobsRow: {
|
|
235
|
+
display: "flex" as const,
|
|
236
|
+
flexDirection: "row" as const,
|
|
237
|
+
flexWrap: "wrap" as const,
|
|
238
|
+
alignItems: "flex-start" as const,
|
|
239
|
+
alignContent: "flex-start" as const,
|
|
240
|
+
gap: 8,
|
|
241
|
+
},
|
|
242
|
+
card: {
|
|
243
|
+
display: "flex" as const,
|
|
244
|
+
flexDirection: "column" as const,
|
|
245
|
+
gap: 4,
|
|
246
|
+
padding: "6px 10px",
|
|
247
|
+
borderRadius: 6,
|
|
248
|
+
border: isDark ? "1px solid #333" : "1px solid #e0e0e0",
|
|
249
|
+
background: isDark ? "#1e1e1e" : "#fafafa",
|
|
250
|
+
minWidth: 140,
|
|
251
|
+
},
|
|
252
|
+
label: {
|
|
253
|
+
fontSize: 11,
|
|
254
|
+
fontWeight: 500,
|
|
255
|
+
color: isDark ? "#888" : "#777",
|
|
256
|
+
whiteSpace: "nowrap" as const,
|
|
257
|
+
textTransform: "uppercase" as const,
|
|
258
|
+
letterSpacing: "0.04em",
|
|
259
|
+
},
|
|
260
|
+
select: {
|
|
261
|
+
padding: "3px 6px",
|
|
262
|
+
borderRadius: 4,
|
|
263
|
+
border: isDark ? "1px solid #444" : "1px solid #ccc",
|
|
264
|
+
background: isDark ? "#2a2a2a" : "#fff",
|
|
265
|
+
color: isDark ? "#e8e8e8" : "#1a1a1a",
|
|
266
|
+
cursor: "pointer",
|
|
267
|
+
fontSize: 13,
|
|
268
|
+
fontFamily: "inherit",
|
|
269
|
+
outline: "none",
|
|
270
|
+
width: "100%",
|
|
271
|
+
},
|
|
272
|
+
toggleRow: {
|
|
273
|
+
display: "flex" as const,
|
|
274
|
+
alignItems: "center" as const,
|
|
275
|
+
gap: 6,
|
|
276
|
+
},
|
|
277
|
+
toggle: (active: boolean) =>
|
|
278
|
+
({
|
|
279
|
+
width: 36,
|
|
280
|
+
height: 20,
|
|
281
|
+
borderRadius: 10,
|
|
282
|
+
background: active ? "#7c6cff" : isDark ? "#444" : "#ccc",
|
|
283
|
+
position: "relative" as const,
|
|
284
|
+
cursor: "pointer",
|
|
285
|
+
border: "none",
|
|
286
|
+
padding: 0,
|
|
287
|
+
transition: "background 0.2s",
|
|
288
|
+
}) as const,
|
|
289
|
+
toggleKnob: (active: boolean) =>
|
|
290
|
+
({
|
|
291
|
+
width: 16,
|
|
292
|
+
height: 16,
|
|
293
|
+
borderRadius: 8,
|
|
294
|
+
background: "#fff",
|
|
295
|
+
position: "absolute" as const,
|
|
296
|
+
top: 2,
|
|
297
|
+
left: active ? 18 : 2,
|
|
298
|
+
transition: "left 0.2s",
|
|
299
|
+
}) as const,
|
|
300
|
+
iconBtn: (active: boolean) =>
|
|
301
|
+
({
|
|
302
|
+
width: 30,
|
|
303
|
+
height: 30,
|
|
304
|
+
borderRadius: 6,
|
|
305
|
+
border: active
|
|
306
|
+
? `1.5px solid ${isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.35)"}`
|
|
307
|
+
: `1px solid ${isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"}`,
|
|
308
|
+
background: active
|
|
309
|
+
? isDark
|
|
310
|
+
? "rgba(255,255,255,0.12)"
|
|
311
|
+
: "rgba(0,0,0,0.06)"
|
|
312
|
+
: "transparent",
|
|
313
|
+
cursor: "pointer",
|
|
314
|
+
display: "flex" as const,
|
|
315
|
+
alignItems: "center" as const,
|
|
316
|
+
justifyContent: "center" as const,
|
|
317
|
+
padding: 0,
|
|
318
|
+
transition: "all 0.15s ease",
|
|
319
|
+
outline: "none",
|
|
320
|
+
}) as const,
|
|
321
|
+
iconRow: {
|
|
322
|
+
display: "flex" as const,
|
|
323
|
+
gap: 2,
|
|
324
|
+
justifyContent: "flex-end" as const,
|
|
325
|
+
},
|
|
326
|
+
resetBtn: {
|
|
327
|
+
padding: "4px 10px",
|
|
328
|
+
borderRadius: 4,
|
|
329
|
+
border: isDark ? "1px solid #555" : "1px solid #ccc",
|
|
330
|
+
background: isDark ? "#2a2a2a" : "#f5f5f5",
|
|
331
|
+
color: isDark ? "#e8e8e8" : "#1a1a1a",
|
|
332
|
+
cursor: "pointer",
|
|
333
|
+
fontSize: 11,
|
|
334
|
+
fontFamily: "inherit",
|
|
335
|
+
fontWeight: 500,
|
|
336
|
+
transition: "all 0.15s",
|
|
337
|
+
whiteSpace: "nowrap" as const,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Sub-components
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function PinCheckbox({
|
|
347
|
+
pinned,
|
|
348
|
+
onToggle,
|
|
349
|
+
isDark,
|
|
350
|
+
}: {
|
|
351
|
+
pinned: boolean;
|
|
352
|
+
onToggle: () => void;
|
|
353
|
+
isDark: boolean;
|
|
354
|
+
}) {
|
|
355
|
+
return (
|
|
356
|
+
<label
|
|
357
|
+
title={pinned ? "Pinned — random won't change this" : "Pin to protect from random"}
|
|
358
|
+
style={{
|
|
359
|
+
position: "absolute" as const,
|
|
360
|
+
top: 3,
|
|
361
|
+
right: 3,
|
|
362
|
+
display: "flex",
|
|
363
|
+
alignItems: "center",
|
|
364
|
+
cursor: "pointer",
|
|
365
|
+
zIndex: 1,
|
|
366
|
+
opacity: pinned ? 1 : 0,
|
|
367
|
+
transition: "opacity 0.15s",
|
|
368
|
+
}}
|
|
369
|
+
className="pin-checkbox"
|
|
370
|
+
>
|
|
371
|
+
<input
|
|
372
|
+
type="checkbox"
|
|
373
|
+
checked={pinned}
|
|
374
|
+
onChange={onToggle}
|
|
375
|
+
style={{
|
|
376
|
+
width: 10,
|
|
377
|
+
height: 10,
|
|
378
|
+
margin: 0,
|
|
379
|
+
cursor: "pointer",
|
|
380
|
+
accentColor: isDark ? "#e8e8e8" : "#1a1a1a",
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
</label>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function Dropdown<V extends string | number | boolean>({
|
|
388
|
+
label,
|
|
389
|
+
options,
|
|
390
|
+
value,
|
|
391
|
+
onChange,
|
|
392
|
+
format,
|
|
393
|
+
styles,
|
|
394
|
+
pinned,
|
|
395
|
+
onTogglePin,
|
|
396
|
+
onUnset,
|
|
397
|
+
isDark,
|
|
398
|
+
children,
|
|
399
|
+
}: {
|
|
400
|
+
label: string;
|
|
401
|
+
options: readonly V[];
|
|
402
|
+
value: V;
|
|
403
|
+
onChange: (v: V) => void;
|
|
404
|
+
format?: (v: V) => string;
|
|
405
|
+
styles: ReturnType<typeof createStyles>;
|
|
406
|
+
pinned?: boolean;
|
|
407
|
+
onTogglePin?: () => void;
|
|
408
|
+
onUnset?: () => void;
|
|
409
|
+
isDark?: boolean;
|
|
410
|
+
children?: ReactNode;
|
|
411
|
+
}) {
|
|
412
|
+
const optionStyle = isDark
|
|
413
|
+
? { backgroundColor: "#1f1f1f", color: "#e8e8e8" }
|
|
414
|
+
: { backgroundColor: "#ffffff", color: "#1a1a1a" };
|
|
415
|
+
return (
|
|
416
|
+
<div
|
|
417
|
+
style={{ ...styles.card, position: "relative" as const }}
|
|
418
|
+
onMouseEnter={(e) => {
|
|
419
|
+
const element = (e.currentTarget as HTMLElement).querySelector(
|
|
420
|
+
".pin-checkbox",
|
|
421
|
+
) as HTMLElement | null;
|
|
422
|
+
if (element && !pinned) element.style.opacity = "0.5";
|
|
423
|
+
}}
|
|
424
|
+
onMouseLeave={(e) => {
|
|
425
|
+
const element = (e.currentTarget as HTMLElement).querySelector(
|
|
426
|
+
".pin-checkbox",
|
|
427
|
+
) as HTMLElement | null;
|
|
428
|
+
if (element && !pinned) element.style.opacity = "0";
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
{onTogglePin && <PinCheckbox pinned={!!pinned} onToggle={onTogglePin} isDark={!!isDark} />}
|
|
432
|
+
<span style={styles.label}>{label}</span>
|
|
433
|
+
<select
|
|
434
|
+
style={{
|
|
435
|
+
...styles.select,
|
|
436
|
+
colorScheme: isDark ? "dark" : "light",
|
|
437
|
+
}}
|
|
438
|
+
value={value === undefined ? "__unset__" : String(value)}
|
|
439
|
+
onChange={(e) => {
|
|
440
|
+
const rawValue = e.target.value;
|
|
441
|
+
if (rawValue === "__unset__") {
|
|
442
|
+
onUnset?.();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const matchedOption = options.find((option) => String(option) === rawValue);
|
|
446
|
+
if (matchedOption !== undefined) onChange(matchedOption);
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
{onUnset && (
|
|
450
|
+
<option value="__unset__" style={optionStyle}>
|
|
451
|
+
unset (inherit)
|
|
452
|
+
</option>
|
|
453
|
+
)}
|
|
454
|
+
{options.map((opt) => (
|
|
455
|
+
<option key={String(opt)} value={String(opt)} style={optionStyle}>
|
|
456
|
+
{format ? format(opt) : String(opt)}
|
|
457
|
+
</option>
|
|
458
|
+
))}
|
|
459
|
+
</select>
|
|
460
|
+
{children}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Icon-based toggle group
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
function CollapseButton({
|
|
470
|
+
expanded,
|
|
471
|
+
onToggle,
|
|
472
|
+
isDark,
|
|
473
|
+
}: {
|
|
474
|
+
expanded: boolean;
|
|
475
|
+
onToggle: () => void;
|
|
476
|
+
isDark: boolean;
|
|
477
|
+
}) {
|
|
478
|
+
return (
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
title={expanded ? "Collapse state overrides" : "Expand state overrides"}
|
|
482
|
+
onClick={onToggle}
|
|
483
|
+
style={{
|
|
484
|
+
background: "none",
|
|
485
|
+
border: "none",
|
|
486
|
+
padding: 0,
|
|
487
|
+
cursor: "pointer",
|
|
488
|
+
display: "flex",
|
|
489
|
+
alignItems: "center",
|
|
490
|
+
color: isDark ? "#666" : "#aaa",
|
|
491
|
+
transition: "transform 0.15s, color 0.15s",
|
|
492
|
+
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
493
|
+
flexShrink: 0,
|
|
494
|
+
}}
|
|
495
|
+
>
|
|
496
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" role="img" aria-label="Toggle">
|
|
497
|
+
<title>Toggle state overrides</title>
|
|
498
|
+
<path
|
|
499
|
+
d="M3 1.5l4 3.5-4 3.5"
|
|
500
|
+
stroke="currentColor"
|
|
501
|
+
strokeWidth="1.5"
|
|
502
|
+
strokeLinecap="round"
|
|
503
|
+
strokeLinejoin="round"
|
|
504
|
+
/>
|
|
505
|
+
</svg>
|
|
506
|
+
</button>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function IconGroup<V extends string | number | boolean>({
|
|
511
|
+
label,
|
|
512
|
+
options,
|
|
513
|
+
value,
|
|
514
|
+
onChange,
|
|
515
|
+
icon,
|
|
516
|
+
styles,
|
|
517
|
+
isDark,
|
|
518
|
+
pinned,
|
|
519
|
+
onTogglePin,
|
|
520
|
+
expanded,
|
|
521
|
+
onToggleExpand,
|
|
522
|
+
onUnset,
|
|
523
|
+
children,
|
|
524
|
+
}: {
|
|
525
|
+
label: string;
|
|
526
|
+
options: readonly V[];
|
|
527
|
+
/** Pass `undefined` to show nothing selected (no overrides). */
|
|
528
|
+
value: V | undefined;
|
|
529
|
+
onChange: (v: V) => void;
|
|
530
|
+
icon: (v: V, active: boolean, dark: boolean) => ReactNode;
|
|
531
|
+
styles: ReturnType<typeof createStyles>;
|
|
532
|
+
isDark: boolean;
|
|
533
|
+
pinned?: boolean;
|
|
534
|
+
onTogglePin?: () => void;
|
|
535
|
+
expanded?: boolean;
|
|
536
|
+
onToggleExpand?: () => void;
|
|
537
|
+
onUnset?: () => void;
|
|
538
|
+
children?: ReactNode;
|
|
539
|
+
}) {
|
|
540
|
+
return (
|
|
541
|
+
<div
|
|
542
|
+
style={{ ...styles.card, position: "relative" as const }}
|
|
543
|
+
onMouseEnter={(e) => {
|
|
544
|
+
const element = (e.currentTarget as HTMLElement).querySelector(
|
|
545
|
+
".pin-checkbox",
|
|
546
|
+
) as HTMLElement | null;
|
|
547
|
+
if (element && !pinned) element.style.opacity = "0.5";
|
|
548
|
+
}}
|
|
549
|
+
onMouseLeave={(e) => {
|
|
550
|
+
const element = (e.currentTarget as HTMLElement).querySelector(
|
|
551
|
+
".pin-checkbox",
|
|
552
|
+
) as HTMLElement | null;
|
|
553
|
+
if (element && !pinned) element.style.opacity = "0";
|
|
554
|
+
}}
|
|
555
|
+
>
|
|
556
|
+
{onTogglePin && <PinCheckbox pinned={!!pinned} onToggle={onTogglePin} isDark={isDark} />}
|
|
557
|
+
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
558
|
+
{onToggleExpand && (
|
|
559
|
+
<CollapseButton expanded={!!expanded} onToggle={onToggleExpand} isDark={isDark} />
|
|
560
|
+
)}
|
|
561
|
+
<span style={styles.label}>{label}</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div style={styles.iconRow}>
|
|
564
|
+
{onUnset && (
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
style={{
|
|
568
|
+
...styles.iconBtn(value === undefined),
|
|
569
|
+
padding: "1px 4px",
|
|
570
|
+
fontSize: 10,
|
|
571
|
+
minWidth: 0,
|
|
572
|
+
}}
|
|
573
|
+
onClick={onUnset}
|
|
574
|
+
title="unset (inherit)"
|
|
575
|
+
>
|
|
576
|
+
↺
|
|
577
|
+
</button>
|
|
578
|
+
)}
|
|
579
|
+
{options.map((opt) => {
|
|
580
|
+
const active = value !== undefined && opt === value;
|
|
581
|
+
return (
|
|
582
|
+
<button
|
|
583
|
+
key={String(opt)}
|
|
584
|
+
type="button"
|
|
585
|
+
style={styles.iconBtn(active)}
|
|
586
|
+
onClick={() => onChange(opt)}
|
|
587
|
+
title={String(opt)}
|
|
588
|
+
>
|
|
589
|
+
{icon(opt, active, isDark)}
|
|
590
|
+
</button>
|
|
591
|
+
);
|
|
592
|
+
})}
|
|
593
|
+
</div>
|
|
594
|
+
{expanded && children}
|
|
595
|
+
</div>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function StateSubRow({
|
|
600
|
+
stateName,
|
|
601
|
+
options,
|
|
602
|
+
value,
|
|
603
|
+
onChange,
|
|
604
|
+
icon,
|
|
605
|
+
styles,
|
|
606
|
+
isDark,
|
|
607
|
+
}: {
|
|
608
|
+
stateName: string;
|
|
609
|
+
options: readonly string[];
|
|
610
|
+
value: string;
|
|
611
|
+
onChange: (v: string) => void;
|
|
612
|
+
icon: (v: string, active: boolean, dark: boolean) => ReactNode;
|
|
613
|
+
styles: ReturnType<typeof createStyles>;
|
|
614
|
+
isDark: boolean;
|
|
615
|
+
}) {
|
|
616
|
+
return (
|
|
617
|
+
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 2 }}>
|
|
618
|
+
<span
|
|
619
|
+
style={{
|
|
620
|
+
fontSize: 9,
|
|
621
|
+
fontWeight: 500,
|
|
622
|
+
color: isDark ? "#666" : "#aaa",
|
|
623
|
+
width: 46,
|
|
624
|
+
textAlign: "right",
|
|
625
|
+
flexShrink: 0,
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
{stateName}
|
|
629
|
+
</span>
|
|
630
|
+
<div style={{ ...styles.iconRow, gap: 2 }}>
|
|
631
|
+
<button
|
|
632
|
+
type="button"
|
|
633
|
+
style={{
|
|
634
|
+
...styles.iconBtn(value === ""),
|
|
635
|
+
padding: "1px 4px",
|
|
636
|
+
fontSize: 9,
|
|
637
|
+
minWidth: 0,
|
|
638
|
+
}}
|
|
639
|
+
onClick={() => onChange("")}
|
|
640
|
+
title="inherit (no override)"
|
|
641
|
+
>
|
|
642
|
+
–
|
|
643
|
+
</button>
|
|
644
|
+
{options.map((opt) => {
|
|
645
|
+
const active = opt === value;
|
|
646
|
+
return (
|
|
647
|
+
<button
|
|
648
|
+
key={opt}
|
|
649
|
+
type="button"
|
|
650
|
+
style={{ ...styles.iconBtn(active), transform: "scale(0.85)" }}
|
|
651
|
+
onClick={() => onChange(opt)}
|
|
652
|
+
title={opt}
|
|
653
|
+
>
|
|
654
|
+
{icon(opt, active, isDark)}
|
|
655
|
+
</button>
|
|
656
|
+
);
|
|
657
|
+
})}
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Icon renderers — wrappers around shared knobIcons with local color logic
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
/** Resolve foreground color for an icon based on active state and theme. */
|
|
668
|
+
function ic(active: boolean, dark: boolean): string {
|
|
669
|
+
return active ? (dark ? "#e8e8e8" : "#1a1a1a") : dark ? "#555" : "#bbb";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function modeIcon(v: "system" | "light" | "dark", active: boolean, dark: boolean): ReactNode {
|
|
673
|
+
return _modeIcon(v, ic(active, dark));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function fillIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
677
|
+
return _fillIcon(v, ic(active, dark));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function spaceIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
681
|
+
return _spaceIcon(v, ic(active, dark));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function sizeIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
685
|
+
return _sizeIcon(v, ic(active, dark));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function borderRadiusIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
689
|
+
return _borderRadiusIcon(v, ic(active, dark));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function borderWidthIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
693
|
+
return _borderWidthIcon(v, ic(active, dark));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function elevationIconWrap(v: string, active: boolean, dark: boolean): ReactNode {
|
|
697
|
+
return _elevationIcon(v, ic(active, dark), dark);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function textAccentIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
701
|
+
return _textAccentIcon(v, ic(active, dark));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function headingFontIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
705
|
+
return _headingFontIcon(v, ic(active, dark));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function bodyFontIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
709
|
+
return _bodyFontIcon(v, ic(active, dark));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function fontWeightIcon(v: string, active: boolean, dark: boolean): ReactNode {
|
|
713
|
+
return _fontWeightIcon(v, ic(active, dark));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Main panel
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
export interface ThemeDevtoolsPanelProps {
|
|
721
|
+
/**
|
|
722
|
+
* Pass `useUserScheme` from `one` or `@vxrn/color-scheme` so the panel can
|
|
723
|
+
* control light/dark/system without importing `one` directly in the theme package.
|
|
724
|
+
*/
|
|
725
|
+
useUserScheme?: () => SchemeHook;
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The current TanStack Devtools panel chrome theme (`"light"` or `"dark"`).
|
|
729
|
+
* Passed automatically when the plugin `render` is a function callback.
|
|
730
|
+
* Used for bidirectional theme syncing and adapting panel styles.
|
|
731
|
+
*/
|
|
732
|
+
devtoolsTheme?: "light" | "dark";
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Additional preset names beyond those registered via `registerPreset()`.
|
|
736
|
+
* Merged with auto-discovered presets from the registry.
|
|
737
|
+
*/
|
|
738
|
+
extraPresets?: string[];
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Available theme color names (e.g. `["blue", "green", "purple", ...]`).
|
|
742
|
+
* When provided, a "Theme" dropdown is shown and colors are included in
|
|
743
|
+
* the randomize action.
|
|
744
|
+
*/
|
|
745
|
+
themeColors?: string[];
|
|
746
|
+
|
|
747
|
+
/** The currently active theme color name (e.g. `"blue"` or `""`). */
|
|
748
|
+
currentThemeColor?: string;
|
|
749
|
+
|
|
750
|
+
/** Callback invoked when the user picks a different theme color. */
|
|
751
|
+
onThemeColorChange?: (color: string) => void;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The Tamagui config object (from `createTamagui()`). When provided, the
|
|
755
|
+
* panel extracts live `$color1`–`$color12` values from the config's themes
|
|
756
|
+
* and passes them to the ColorLineVisualizer. This avoids relying on DOM
|
|
757
|
+
* CSS variable reading which doesn't work inside shadow DOM / isolated
|
|
758
|
+
* rendering contexts like TanStack Devtools.
|
|
759
|
+
*/
|
|
760
|
+
tamaguiConfig?: unknown;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Extract 12 tokens from a Tamagui config for a given theme name.
|
|
765
|
+
* @param prefix - Token prefix: `"color"` for `$color1`–`$color12`,
|
|
766
|
+
* `"accent"` for `$accent1`–`$accent12`.
|
|
767
|
+
* Tries the exact sub-theme name first, then falls back to the base scheme.
|
|
768
|
+
*/
|
|
769
|
+
function extractThemeTokenColors(
|
|
770
|
+
tamaguiConfig: unknown,
|
|
771
|
+
scheme: "light" | "dark",
|
|
772
|
+
colorTheme?: string,
|
|
773
|
+
prefix: "color" | "accent" = "color",
|
|
774
|
+
): string[] | undefined {
|
|
775
|
+
const config = tamaguiConfig as Record<string, unknown> | null;
|
|
776
|
+
const themes = config?.themes as Record<string, Record<string, unknown>> | undefined;
|
|
777
|
+
if (!themes) return undefined;
|
|
778
|
+
|
|
779
|
+
const candidates: string[] = [];
|
|
780
|
+
if (colorTheme) candidates.push(`${scheme}_${colorTheme}`);
|
|
781
|
+
candidates.push(scheme);
|
|
782
|
+
|
|
783
|
+
for (const name of candidates) {
|
|
784
|
+
const theme = themes[name];
|
|
785
|
+
if (!theme) continue;
|
|
786
|
+
const colors: string[] = [];
|
|
787
|
+
let found = false;
|
|
788
|
+
for (let i = 1; i <= 12; i++) {
|
|
789
|
+
const token = theme[`${prefix}${i}`];
|
|
790
|
+
let val: string | undefined;
|
|
791
|
+
if (typeof token === "string") {
|
|
792
|
+
val = token;
|
|
793
|
+
} else if (
|
|
794
|
+
token &&
|
|
795
|
+
typeof token === "object" &&
|
|
796
|
+
"val" in (token as Record<string, unknown>)
|
|
797
|
+
) {
|
|
798
|
+
val = String((token as { val: unknown }).val);
|
|
799
|
+
}
|
|
800
|
+
if (val) {
|
|
801
|
+
colors.push(val);
|
|
802
|
+
found = true;
|
|
803
|
+
} else {
|
|
804
|
+
colors.push("");
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (found) return colors;
|
|
808
|
+
}
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function ThemeDevtoolsPanel({
|
|
813
|
+
useUserScheme,
|
|
814
|
+
extraPresets,
|
|
815
|
+
devtoolsTheme,
|
|
816
|
+
themeColors,
|
|
817
|
+
currentThemeColor,
|
|
818
|
+
onThemeColorChange,
|
|
819
|
+
tamaguiConfig,
|
|
820
|
+
}: ThemeDevtoolsPanelProps) {
|
|
821
|
+
const [knobs, setKnobs, , setPreset, activePresetName] = useTheme();
|
|
822
|
+
const scheme = useUserScheme?.();
|
|
823
|
+
const isDark = devtoolsTheme !== "light";
|
|
824
|
+
const styles = useMemo(() => createStyles(isDark), [isDark]);
|
|
825
|
+
const animationOptions = useMemo(
|
|
826
|
+
() => ["none", ...extractAnimationNames(tamaguiConfig)],
|
|
827
|
+
[tamaguiConfig],
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
// Track which knobs the user has explicitly changed in this session.
|
|
831
|
+
const [overrides, setOverrides] = useState<Set<string>>(new Set());
|
|
832
|
+
const [pinnedKnobs, setPinnedKnobs] = useState<Set<string>>(() => {
|
|
833
|
+
try {
|
|
834
|
+
const raw = storage.getItem(pinnedKnobsStorageKey);
|
|
835
|
+
const stored = typeof raw === "string" || raw === null ? raw : null;
|
|
836
|
+
if (stored) return new Set(JSON.parse(stored));
|
|
837
|
+
} catch {}
|
|
838
|
+
return new Set();
|
|
839
|
+
});
|
|
840
|
+
const [activeProfile, setActiveProfile] = useState("");
|
|
841
|
+
|
|
842
|
+
const [expandedKnobs, setExpandedKnobs] = useState<Set<string>>(() => {
|
|
843
|
+
try {
|
|
844
|
+
const raw = storage.getItem(expandedKnobsStorageKey);
|
|
845
|
+
const stored = typeof raw === "string" || raw === null ? raw : null;
|
|
846
|
+
if (stored) {
|
|
847
|
+
const parsed = JSON.parse(stored) as string[];
|
|
848
|
+
return new Set(parsed.filter((k) => stateKnobKeys.includes(k as keyof Knobs)));
|
|
849
|
+
}
|
|
850
|
+
} catch {}
|
|
851
|
+
return new Set();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const knobsRef = useRef(knobs);
|
|
855
|
+
knobsRef.current = knobs;
|
|
856
|
+
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
void Promise.resolve(
|
|
859
|
+
storage.setItem(pinnedKnobsStorageKey, JSON.stringify([...pinnedKnobs])),
|
|
860
|
+
).catch(() => {});
|
|
861
|
+
}, [pinnedKnobs]);
|
|
862
|
+
|
|
863
|
+
useEffect(() => {
|
|
864
|
+
void Promise.resolve(
|
|
865
|
+
storage.setItem(expandedKnobsStorageKey, JSON.stringify([...expandedKnobs])),
|
|
866
|
+
).catch(() => {});
|
|
867
|
+
}, [expandedKnobs]);
|
|
868
|
+
|
|
869
|
+
const clearStateOverridesForKeys = useCallback(
|
|
870
|
+
(keys: string[]) => {
|
|
871
|
+
const current = knobsRef.current;
|
|
872
|
+
const partial: Partial<Knobs> = {};
|
|
873
|
+
for (const state of interactionStates) {
|
|
874
|
+
const stateKnobs = { ...current[state] } as Record<string, unknown>;
|
|
875
|
+
let mutated = false;
|
|
876
|
+
for (const key of keys) {
|
|
877
|
+
if (stateKnobs[key] !== undefined) {
|
|
878
|
+
stateKnobs[key] = undefined;
|
|
879
|
+
mutated = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (mutated) {
|
|
883
|
+
(partial as Record<string, unknown>)[state] = stateKnobs;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (Object.keys(partial).length > 0) {
|
|
887
|
+
setKnobs(partial);
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
[setKnobs],
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const toggleExpand = useCallback(
|
|
894
|
+
(key: string) => {
|
|
895
|
+
let collapsing = false;
|
|
896
|
+
setExpandedKnobs((prev) => {
|
|
897
|
+
const next = new Set(prev);
|
|
898
|
+
if (next.has(key)) {
|
|
899
|
+
collapsing = true;
|
|
900
|
+
next.delete(key);
|
|
901
|
+
} else {
|
|
902
|
+
next.add(key);
|
|
903
|
+
}
|
|
904
|
+
return next;
|
|
905
|
+
});
|
|
906
|
+
if (collapsing) {
|
|
907
|
+
clearStateOverridesForKeys([key]);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
[clearStateOverridesForKeys],
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
const togglePin = useCallback((key: string) => {
|
|
914
|
+
setPinnedKnobs((prev) => {
|
|
915
|
+
const next = new Set(prev);
|
|
916
|
+
if (next.has(key)) {
|
|
917
|
+
next.delete(key);
|
|
918
|
+
for (const state of interactionStates) {
|
|
919
|
+
next.delete(`${state}.${key}`);
|
|
920
|
+
}
|
|
921
|
+
} else {
|
|
922
|
+
next.add(key);
|
|
923
|
+
for (const state of interactionStates) {
|
|
924
|
+
next.add(`${state}.${key}`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return next;
|
|
928
|
+
});
|
|
929
|
+
}, []);
|
|
930
|
+
|
|
931
|
+
const allStateKnobsExpanded = useMemo(
|
|
932
|
+
() => stateKnobKeys.every((key) => expandedKnobs.has(key)),
|
|
933
|
+
[expandedKnobs],
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
const handleToggleAllStates = useCallback(() => {
|
|
937
|
+
let collapsingAll = false;
|
|
938
|
+
setExpandedKnobs((prev) => {
|
|
939
|
+
const allExpanded = stateKnobKeys.every((key) => prev.has(key));
|
|
940
|
+
if (allExpanded) {
|
|
941
|
+
collapsingAll = true;
|
|
942
|
+
return new Set();
|
|
943
|
+
}
|
|
944
|
+
return new Set(stateKnobKeys as readonly string[]);
|
|
945
|
+
});
|
|
946
|
+
if (collapsingAll) {
|
|
947
|
+
clearStateOverridesForKeys(stateKnobKeys as string[]);
|
|
948
|
+
}
|
|
949
|
+
}, [clearStateOverridesForKeys]);
|
|
950
|
+
|
|
951
|
+
const set = useCallback(
|
|
952
|
+
<K extends keyof Knobs>(key: K, value: Knobs[K]) => {
|
|
953
|
+
setOverrides((prev) => new Set(prev).add(key));
|
|
954
|
+
setKnobs({ [key]: value } as Partial<Knobs>);
|
|
955
|
+
if (activeProfile) setActiveProfile("");
|
|
956
|
+
},
|
|
957
|
+
[setKnobs, activeProfile],
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
const handleProfileChange = useCallback(
|
|
961
|
+
(profileName: string) => {
|
|
962
|
+
if (!profileName) {
|
|
963
|
+
setActiveProfile("");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const profile = designProfiles[profileName];
|
|
967
|
+
if (!profile) return;
|
|
968
|
+
const partial: Partial<Knobs> = {};
|
|
969
|
+
for (const key of knobKeys) {
|
|
970
|
+
if (profile[key]) (partial as Record<string, string>)[key] = profile[key];
|
|
971
|
+
}
|
|
972
|
+
const hover: Record<string, string> = {};
|
|
973
|
+
const press: Record<string, string> = {};
|
|
974
|
+
for (const [k, v] of Object.entries(profile)) {
|
|
975
|
+
if (!v) continue;
|
|
976
|
+
if (k.startsWith("hover.")) hover[k.slice(6)] = v;
|
|
977
|
+
else if (k.startsWith("press.")) press[k.slice(6)] = v;
|
|
978
|
+
}
|
|
979
|
+
if (Object.keys(hover).length) partial.hover = hover;
|
|
980
|
+
if (Object.keys(press).length) partial.press = press;
|
|
981
|
+
setKnobs(partial);
|
|
982
|
+
setOverrides(new Set(knobKeys as readonly string[]));
|
|
983
|
+
setPinnedKnobs(new Set());
|
|
984
|
+
setActiveProfile(profileName);
|
|
985
|
+
if (profile.color && onThemeColorChange) {
|
|
986
|
+
onThemeColorChange(profile.color);
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
[setKnobs, onThemeColorChange],
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
const handleClearKnobs = useCallback(() => {
|
|
993
|
+
if (activeProfile && designProfiles[activeProfile]) {
|
|
994
|
+
handleProfileChange(activeProfile);
|
|
995
|
+
setExpandedKnobs(new Set());
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
setOverrides(new Set());
|
|
999
|
+
setPinnedKnobs(new Set());
|
|
1000
|
+
setExpandedKnobs(new Set());
|
|
1001
|
+
setActiveProfile("");
|
|
1002
|
+
if (activePresetName) {
|
|
1003
|
+
setPreset(activePresetName);
|
|
1004
|
+
} else {
|
|
1005
|
+
setKnobs({ ...defaultKnobs });
|
|
1006
|
+
}
|
|
1007
|
+
}, [activeProfile, handleProfileChange, activePresetName, setPreset, setKnobs]);
|
|
1008
|
+
|
|
1009
|
+
const handleApplyDefaults = useCallback(() => {
|
|
1010
|
+
setOverrides(new Set(knobKeys as readonly string[]));
|
|
1011
|
+
setPinnedKnobs(new Set());
|
|
1012
|
+
setExpandedKnobs(new Set());
|
|
1013
|
+
setActiveProfile("");
|
|
1014
|
+
setPreset("");
|
|
1015
|
+
setKnobs({ ...defaultKnobs });
|
|
1016
|
+
}, [setPreset, setKnobs]);
|
|
1017
|
+
|
|
1018
|
+
// ── Copy Snapshot ───────────────────────────────────────────
|
|
1019
|
+
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">("idle");
|
|
1020
|
+
|
|
1021
|
+
const handleCopySnapshot = useCallback(async () => {
|
|
1022
|
+
const knobValues: Record<string, string> = {};
|
|
1023
|
+
for (const [key, value] of Object.entries(knobs)) {
|
|
1024
|
+
if (value && typeof value === "object") {
|
|
1025
|
+
for (const [sk, sv] of Object.entries(value as Record<string, string>)) {
|
|
1026
|
+
if (sv) knobValues[`${key}.${sk}`] = sv;
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
knobValues[key] = String(value);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const lightColors = extractThemeTokenColors(tamaguiConfig, "light", currentThemeColor) ?? [];
|
|
1033
|
+
const darkColors = extractThemeTokenColors(tamaguiConfig, "dark", currentThemeColor) ?? [];
|
|
1034
|
+
|
|
1035
|
+
const snapshot = buildSnapshot({
|
|
1036
|
+
knobValues,
|
|
1037
|
+
activePreset: activePresetName ?? undefined,
|
|
1038
|
+
schemeSetting: scheme?.setting,
|
|
1039
|
+
schemeValue: scheme?.value,
|
|
1040
|
+
currentColor: currentThemeColor,
|
|
1041
|
+
lightColors,
|
|
1042
|
+
darkColors,
|
|
1043
|
+
});
|
|
1044
|
+
const success = await writeToClipboard(snapshot);
|
|
1045
|
+
if (success) {
|
|
1046
|
+
setCopyStatus("copied");
|
|
1047
|
+
setTimeout(() => setCopyStatus("idle"), 2000);
|
|
1048
|
+
} else {
|
|
1049
|
+
setCopyStatus("error");
|
|
1050
|
+
setTimeout(() => setCopyStatus("idle"), 2000);
|
|
1051
|
+
}
|
|
1052
|
+
}, [knobs, activePresetName, scheme, currentThemeColor, tamaguiConfig]);
|
|
1053
|
+
|
|
1054
|
+
const handleRandomize = useCallback(() => {
|
|
1055
|
+
const pick = <T,>(arr: readonly T[]): T => arr[Math.floor(Math.random() * arr.length)] as T;
|
|
1056
|
+
const knobEnums: Record<string, readonly string[]> = {
|
|
1057
|
+
fillStyle: Object.values(FillStyle),
|
|
1058
|
+
borderRadius: Object.values(BorderRadius),
|
|
1059
|
+
borderWidth: Object.values(BorderWidth),
|
|
1060
|
+
elevation: Object.values(Elevation),
|
|
1061
|
+
space: Object.values(Space),
|
|
1062
|
+
size: Object.values(Size),
|
|
1063
|
+
textAccent: Object.values(TextAccent),
|
|
1064
|
+
headingFont: Object.values(HeadingFont),
|
|
1065
|
+
bodyFont: Object.values(BodyFont),
|
|
1066
|
+
fontWeight: Object.values(FontWeight),
|
|
1067
|
+
animation: animationOptions,
|
|
1068
|
+
};
|
|
1069
|
+
const partial: Partial<Knobs> = {};
|
|
1070
|
+
const newOverrides = new Set(knobKeys as readonly string[]);
|
|
1071
|
+
for (const key of knobKeys) {
|
|
1072
|
+
if (pinnedKnobs.has(key)) continue;
|
|
1073
|
+
const values = knobEnums[key];
|
|
1074
|
+
if (values) (partial as Record<string, string>)[key] = pick(values);
|
|
1075
|
+
}
|
|
1076
|
+
if (expandedKnobs.size > 0) {
|
|
1077
|
+
const knobEnumsForState: Record<string, readonly string[]> = {
|
|
1078
|
+
fillStyle: Object.values(FillStyle),
|
|
1079
|
+
borderRadius: Object.values(BorderRadius),
|
|
1080
|
+
borderWidth: Object.values(BorderWidth),
|
|
1081
|
+
elevation: Object.values(Elevation),
|
|
1082
|
+
space: Object.values(Space),
|
|
1083
|
+
textAccent: Object.values(TextAccent),
|
|
1084
|
+
headingFont: Object.values(HeadingFont),
|
|
1085
|
+
bodyFont: Object.values(BodyFont),
|
|
1086
|
+
fontWeight: Object.values(FontWeight),
|
|
1087
|
+
};
|
|
1088
|
+
for (const state of interactionStates) {
|
|
1089
|
+
const statePartial: Record<string, string> = {};
|
|
1090
|
+
for (const key of expandedKnobs) {
|
|
1091
|
+
const values = knobEnumsForState[key];
|
|
1092
|
+
if (values) statePartial[key] = pick(values);
|
|
1093
|
+
}
|
|
1094
|
+
if (Object.keys(statePartial).length > 0) {
|
|
1095
|
+
(partial as Record<string, unknown>)[state] = statePartial;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const fill = partial.fillStyle ?? knobs.fillStyle;
|
|
1100
|
+
const borderWidth = partial.borderWidth ?? knobs.borderWidth;
|
|
1101
|
+
if (fill === "outlined" && borderWidth === "none") {
|
|
1102
|
+
if (!pinnedKnobs.has("borderWidth")) {
|
|
1103
|
+
partial.borderWidth = pick(["small", "medium", "large"] as const);
|
|
1104
|
+
} else {
|
|
1105
|
+
partial.fillStyle = "filled";
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
setOverrides(newOverrides);
|
|
1109
|
+
setKnobs(partial);
|
|
1110
|
+
if (!pinnedKnobs.has("color") && themeColors && onThemeColorChange) {
|
|
1111
|
+
const nonEmpty = themeColors.filter((c) => c !== "");
|
|
1112
|
+
if (nonEmpty.length > 0) {
|
|
1113
|
+
onThemeColorChange(pick(nonEmpty));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
if (!pinnedKnobs.has("scheme") && scheme) {
|
|
1117
|
+
scheme.set(pick(["light", "dark"] as const));
|
|
1118
|
+
}
|
|
1119
|
+
if (activeProfile) setActiveProfile("");
|
|
1120
|
+
}, [
|
|
1121
|
+
setKnobs,
|
|
1122
|
+
themeColors,
|
|
1123
|
+
onThemeColorChange,
|
|
1124
|
+
scheme,
|
|
1125
|
+
pinnedKnobs,
|
|
1126
|
+
expandedKnobs,
|
|
1127
|
+
activeProfile,
|
|
1128
|
+
animationOptions,
|
|
1129
|
+
knobs.fillStyle,
|
|
1130
|
+
knobs.borderWidth,
|
|
1131
|
+
]);
|
|
1132
|
+
|
|
1133
|
+
// -------------------------------------------------------------------------
|
|
1134
|
+
// Preset picker
|
|
1135
|
+
// -------------------------------------------------------------------------
|
|
1136
|
+
const presetOptions = useMemo(() => {
|
|
1137
|
+
const registered = getPresetNames();
|
|
1138
|
+
const names = new Set<string>(registered);
|
|
1139
|
+
if (extraPresets) {
|
|
1140
|
+
for (const name of extraPresets) {
|
|
1141
|
+
names.add(name);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return ["", ...Array.from(names).filter(Boolean).sort()];
|
|
1145
|
+
}, [extraPresets]);
|
|
1146
|
+
|
|
1147
|
+
const handlePresetChange = useCallback(
|
|
1148
|
+
(presetName: string) => {
|
|
1149
|
+
if (!presetName) return;
|
|
1150
|
+
setPreset(presetName);
|
|
1151
|
+
setOverrides(new Set());
|
|
1152
|
+
},
|
|
1153
|
+
[setPreset],
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
const handleResetToPreset = useCallback(() => {
|
|
1157
|
+
if (activePresetName) {
|
|
1158
|
+
setPreset(activePresetName);
|
|
1159
|
+
setOverrides(new Set());
|
|
1160
|
+
}
|
|
1161
|
+
}, [activePresetName, setPreset]);
|
|
1162
|
+
|
|
1163
|
+
// -------------------------------------------------------------------------
|
|
1164
|
+
// Bidirectional theme sync: devtools panel ↔ website color scheme
|
|
1165
|
+
// -------------------------------------------------------------------------
|
|
1166
|
+
//
|
|
1167
|
+
// The scheme (light/dark/system) is orthogonal to knobs and managed entirely
|
|
1168
|
+
// by `useUserScheme` (SchemeProvider / @vxrn/color-scheme). The devtools
|
|
1169
|
+
// chrome theme and the website color scheme are kept in sync here.
|
|
1170
|
+
|
|
1171
|
+
const prevDevtoolsTheme = useRef<string | undefined>(undefined);
|
|
1172
|
+
|
|
1173
|
+
useEffect(() => {
|
|
1174
|
+
if (!devtoolsTheme || !scheme) {
|
|
1175
|
+
prevDevtoolsTheme.current = devtoolsTheme;
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (prevDevtoolsTheme.current === undefined) {
|
|
1179
|
+
prevDevtoolsTheme.current = devtoolsTheme;
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (prevDevtoolsTheme.current !== devtoolsTheme) {
|
|
1183
|
+
prevDevtoolsTheme.current = devtoolsTheme;
|
|
1184
|
+
if (scheme.value !== devtoolsTheme) {
|
|
1185
|
+
scheme.set(devtoolsTheme);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}, [devtoolsTheme, scheme]);
|
|
1189
|
+
|
|
1190
|
+
const schemeValue = scheme?.value;
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
if (!schemeValue) return;
|
|
1193
|
+
void Promise.resolve(storage.getItem(tanstackDevtoolsSettings))
|
|
1194
|
+
.then((raw) => {
|
|
1195
|
+
const settings = raw ? JSON.parse(raw) : {};
|
|
1196
|
+
if (settings.theme !== schemeValue) {
|
|
1197
|
+
settings.theme = schemeValue;
|
|
1198
|
+
return Promise.resolve(
|
|
1199
|
+
storage.setItem(tanstackDevtoolsSettings, JSON.stringify(settings)),
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
})
|
|
1203
|
+
.catch(() => {
|
|
1204
|
+
// storage may be unavailable (e.g. SSR, iframe sandbox)
|
|
1205
|
+
});
|
|
1206
|
+
}, [schemeValue]);
|
|
1207
|
+
|
|
1208
|
+
/** Return the value only if the user has explicitly overridden it, else undefined. */
|
|
1209
|
+
const getOverride = <K extends keyof Knobs>(key: K): Knobs[K] | undefined =>
|
|
1210
|
+
overrides.has(key) ? knobs[key] : undefined;
|
|
1211
|
+
|
|
1212
|
+
const inheritedKnob = useCallback(
|
|
1213
|
+
<K extends keyof Knobs>(key: K): Knobs[K] => {
|
|
1214
|
+
const activeProfileConfig = activeProfile ? designProfiles[activeProfile] : undefined;
|
|
1215
|
+
if (activeProfileConfig?.[key]) {
|
|
1216
|
+
return activeProfileConfig[key] as Knobs[K];
|
|
1217
|
+
}
|
|
1218
|
+
if (activePresetName) {
|
|
1219
|
+
const presetKnobs = getPreset(activePresetName)?.knobs;
|
|
1220
|
+
if (presetKnobs?.[key] !== undefined) return presetKnobs[key];
|
|
1221
|
+
}
|
|
1222
|
+
return defaultKnobs[key];
|
|
1223
|
+
},
|
|
1224
|
+
[activeProfile, activePresetName],
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1227
|
+
const unset = useCallback(
|
|
1228
|
+
<K extends keyof Knobs>(key: K) => {
|
|
1229
|
+
setOverrides((prev) => {
|
|
1230
|
+
const next = new Set(prev);
|
|
1231
|
+
next.delete(key);
|
|
1232
|
+
return next;
|
|
1233
|
+
});
|
|
1234
|
+
setKnobs({ [key]: inheritedKnob(key) } as Partial<Knobs>);
|
|
1235
|
+
},
|
|
1236
|
+
[setKnobs, inheritedKnob],
|
|
1237
|
+
);
|
|
1238
|
+
|
|
1239
|
+
const setStateKnob = useCallback(
|
|
1240
|
+
(state: "hover" | "press" | "focus" | "focusVisible", key: string, value: string) => {
|
|
1241
|
+
const current = knobsRef.current;
|
|
1242
|
+
setKnobs({
|
|
1243
|
+
[state]: { ...current[state], [key]: value === "" ? undefined : value },
|
|
1244
|
+
} as Partial<Knobs>);
|
|
1245
|
+
setOverrides((prev) => new Set(prev).add(state));
|
|
1246
|
+
if (activeProfile) setActiveProfile("");
|
|
1247
|
+
},
|
|
1248
|
+
[setKnobs, activeProfile],
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
const getStateKnob = (
|
|
1252
|
+
state: "hover" | "press" | "focus" | "focusVisible",
|
|
1253
|
+
key: string,
|
|
1254
|
+
): string => {
|
|
1255
|
+
const stateKnobs = knobs[state];
|
|
1256
|
+
return (stateKnobs as Record<string, string | undefined>)[key] ?? "";
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const stateRows = (
|
|
1260
|
+
knobKey: string,
|
|
1261
|
+
options: readonly string[],
|
|
1262
|
+
icon: (v: string, active: boolean, dark: boolean) => ReactNode,
|
|
1263
|
+
) =>
|
|
1264
|
+
interactionStates.map((state) => (
|
|
1265
|
+
<StateSubRow
|
|
1266
|
+
key={state}
|
|
1267
|
+
stateName={stateDisplayLabels[state]}
|
|
1268
|
+
options={options}
|
|
1269
|
+
value={getStateKnob(state, knobKey)}
|
|
1270
|
+
onChange={(v) => setStateKnob(state, knobKey, v)}
|
|
1271
|
+
icon={icon}
|
|
1272
|
+
styles={styles}
|
|
1273
|
+
isDark={isDark}
|
|
1274
|
+
/>
|
|
1275
|
+
));
|
|
1276
|
+
|
|
1277
|
+
return (
|
|
1278
|
+
<div style={styles.panel}>
|
|
1279
|
+
{/* ── Knobs ── */}
|
|
1280
|
+
<div style={styles.knobsRow}>
|
|
1281
|
+
{presetOptions.length > 1 && (
|
|
1282
|
+
<Dropdown
|
|
1283
|
+
label="Preset"
|
|
1284
|
+
options={presetOptions}
|
|
1285
|
+
value={activePresetName ?? ""}
|
|
1286
|
+
onChange={(v) => handlePresetChange(v)}
|
|
1287
|
+
format={(v) => (v === "" ? "custom" : String(v))}
|
|
1288
|
+
styles={styles}
|
|
1289
|
+
/>
|
|
1290
|
+
)}
|
|
1291
|
+
{presetOptions.length > 1 && (
|
|
1292
|
+
<div style={styles.card}>
|
|
1293
|
+
<button type="button" style={styles.resetBtn} onClick={handleResetToPreset}>
|
|
1294
|
+
Reset to Preset
|
|
1295
|
+
</button>
|
|
1296
|
+
</div>
|
|
1297
|
+
)}
|
|
1298
|
+
{themeColors && themeColors.length > 1 && onThemeColorChange && (
|
|
1299
|
+
<Dropdown
|
|
1300
|
+
label="Theme"
|
|
1301
|
+
options={themeColors}
|
|
1302
|
+
value={currentThemeColor ?? ""}
|
|
1303
|
+
onChange={(v) => onThemeColorChange(v)}
|
|
1304
|
+
format={(v) => (v === "" ? "none" : String(v))}
|
|
1305
|
+
styles={styles}
|
|
1306
|
+
pinned={pinnedKnobs.has("color")}
|
|
1307
|
+
onTogglePin={() => togglePin("color")}
|
|
1308
|
+
isDark={isDark}
|
|
1309
|
+
/>
|
|
1310
|
+
)}
|
|
1311
|
+
{scheme && (
|
|
1312
|
+
<IconGroup
|
|
1313
|
+
label="Scheme"
|
|
1314
|
+
options={["system", "light", "dark"] as const}
|
|
1315
|
+
value={scheme.setting}
|
|
1316
|
+
onChange={(v) => scheme.set(v)}
|
|
1317
|
+
icon={modeIcon}
|
|
1318
|
+
styles={styles}
|
|
1319
|
+
isDark={isDark}
|
|
1320
|
+
pinned={pinnedKnobs.has("scheme")}
|
|
1321
|
+
onTogglePin={() => togglePin("scheme")}
|
|
1322
|
+
/>
|
|
1323
|
+
)}
|
|
1324
|
+
<IconGroup
|
|
1325
|
+
label="Fill"
|
|
1326
|
+
options={Object.values(FillStyle)}
|
|
1327
|
+
value={getOverride("fillStyle")}
|
|
1328
|
+
onChange={(v) => set("fillStyle", v)}
|
|
1329
|
+
icon={fillIcon}
|
|
1330
|
+
styles={styles}
|
|
1331
|
+
isDark={isDark}
|
|
1332
|
+
pinned={pinnedKnobs.has("fillStyle")}
|
|
1333
|
+
onTogglePin={() => togglePin("fillStyle")}
|
|
1334
|
+
expanded={expandedKnobs.has("fillStyle")}
|
|
1335
|
+
onToggleExpand={() => toggleExpand("fillStyle")}
|
|
1336
|
+
onUnset={() => unset("fillStyle")}
|
|
1337
|
+
>
|
|
1338
|
+
{stateRows("fillStyle", Object.values(FillStyle), fillIcon)}
|
|
1339
|
+
</IconGroup>
|
|
1340
|
+
<IconGroup
|
|
1341
|
+
label="Size"
|
|
1342
|
+
options={Object.values(Size)}
|
|
1343
|
+
value={getOverride("size")}
|
|
1344
|
+
onChange={(v) => set("size", v)}
|
|
1345
|
+
icon={sizeIcon}
|
|
1346
|
+
styles={styles}
|
|
1347
|
+
isDark={isDark}
|
|
1348
|
+
pinned={pinnedKnobs.has("size")}
|
|
1349
|
+
onTogglePin={() => togglePin("size")}
|
|
1350
|
+
onUnset={() => unset("size")}
|
|
1351
|
+
/>
|
|
1352
|
+
<IconGroup
|
|
1353
|
+
label="Space"
|
|
1354
|
+
options={Object.values(Space)}
|
|
1355
|
+
value={getOverride("space")}
|
|
1356
|
+
onChange={(v) => set("space", v)}
|
|
1357
|
+
icon={spaceIcon}
|
|
1358
|
+
styles={styles}
|
|
1359
|
+
isDark={isDark}
|
|
1360
|
+
pinned={pinnedKnobs.has("space")}
|
|
1361
|
+
onTogglePin={() => togglePin("space")}
|
|
1362
|
+
expanded={expandedKnobs.has("space")}
|
|
1363
|
+
onToggleExpand={() => toggleExpand("space")}
|
|
1364
|
+
onUnset={() => unset("space")}
|
|
1365
|
+
>
|
|
1366
|
+
{stateRows("space", Object.values(Space), spaceIcon)}
|
|
1367
|
+
</IconGroup>
|
|
1368
|
+
<IconGroup
|
|
1369
|
+
label="Radius"
|
|
1370
|
+
options={Object.values(BorderRadius)}
|
|
1371
|
+
value={getOverride("borderRadius")}
|
|
1372
|
+
onChange={(v) => set("borderRadius", v)}
|
|
1373
|
+
icon={borderRadiusIcon}
|
|
1374
|
+
styles={styles}
|
|
1375
|
+
isDark={isDark}
|
|
1376
|
+
pinned={pinnedKnobs.has("borderRadius")}
|
|
1377
|
+
onTogglePin={() => togglePin("borderRadius")}
|
|
1378
|
+
expanded={expandedKnobs.has("borderRadius")}
|
|
1379
|
+
onToggleExpand={() => toggleExpand("borderRadius")}
|
|
1380
|
+
onUnset={() => unset("borderRadius")}
|
|
1381
|
+
>
|
|
1382
|
+
{stateRows("borderRadius", Object.values(BorderRadius), borderRadiusIcon)}
|
|
1383
|
+
</IconGroup>
|
|
1384
|
+
<IconGroup
|
|
1385
|
+
label="Border"
|
|
1386
|
+
options={Object.values(BorderWidth)}
|
|
1387
|
+
value={getOverride("borderWidth")}
|
|
1388
|
+
onChange={(v) => set("borderWidth", v)}
|
|
1389
|
+
icon={borderWidthIcon}
|
|
1390
|
+
styles={styles}
|
|
1391
|
+
isDark={isDark}
|
|
1392
|
+
pinned={pinnedKnobs.has("borderWidth")}
|
|
1393
|
+
onTogglePin={() => togglePin("borderWidth")}
|
|
1394
|
+
expanded={expandedKnobs.has("borderWidth")}
|
|
1395
|
+
onToggleExpand={() => toggleExpand("borderWidth")}
|
|
1396
|
+
onUnset={() => unset("borderWidth")}
|
|
1397
|
+
>
|
|
1398
|
+
{stateRows("borderWidth", Object.values(BorderWidth), borderWidthIcon)}
|
|
1399
|
+
</IconGroup>
|
|
1400
|
+
<IconGroup
|
|
1401
|
+
label="Elevation"
|
|
1402
|
+
options={Object.values(Elevation)}
|
|
1403
|
+
value={getOverride("elevation")}
|
|
1404
|
+
onChange={(v) => set("elevation", v)}
|
|
1405
|
+
icon={elevationIconWrap}
|
|
1406
|
+
styles={styles}
|
|
1407
|
+
isDark={isDark}
|
|
1408
|
+
pinned={pinnedKnobs.has("elevation")}
|
|
1409
|
+
onTogglePin={() => togglePin("elevation")}
|
|
1410
|
+
expanded={expandedKnobs.has("elevation")}
|
|
1411
|
+
onToggleExpand={() => toggleExpand("elevation")}
|
|
1412
|
+
onUnset={() => unset("elevation")}
|
|
1413
|
+
>
|
|
1414
|
+
{stateRows("elevation", Object.values(Elevation), elevationIconWrap)}
|
|
1415
|
+
</IconGroup>
|
|
1416
|
+
<IconGroup
|
|
1417
|
+
label="Text Accent"
|
|
1418
|
+
options={Object.values(TextAccent)}
|
|
1419
|
+
value={getOverride("textAccent")}
|
|
1420
|
+
onChange={(v) => set("textAccent", v)}
|
|
1421
|
+
icon={textAccentIcon}
|
|
1422
|
+
styles={styles}
|
|
1423
|
+
isDark={isDark}
|
|
1424
|
+
pinned={pinnedKnobs.has("textAccent")}
|
|
1425
|
+
onTogglePin={() => togglePin("textAccent")}
|
|
1426
|
+
expanded={expandedKnobs.has("textAccent")}
|
|
1427
|
+
onToggleExpand={() => toggleExpand("textAccent")}
|
|
1428
|
+
onUnset={() => unset("textAccent")}
|
|
1429
|
+
>
|
|
1430
|
+
{stateRows("textAccent", Object.values(TextAccent), textAccentIcon)}
|
|
1431
|
+
</IconGroup>
|
|
1432
|
+
<IconGroup
|
|
1433
|
+
label="Heading"
|
|
1434
|
+
options={Object.values(HeadingFont)}
|
|
1435
|
+
value={getOverride("headingFont")}
|
|
1436
|
+
onChange={(v) => set("headingFont", v)}
|
|
1437
|
+
icon={headingFontIcon}
|
|
1438
|
+
styles={styles}
|
|
1439
|
+
isDark={isDark}
|
|
1440
|
+
pinned={pinnedKnobs.has("headingFont")}
|
|
1441
|
+
onTogglePin={() => togglePin("headingFont")}
|
|
1442
|
+
expanded={expandedKnobs.has("headingFont")}
|
|
1443
|
+
onToggleExpand={() => toggleExpand("headingFont")}
|
|
1444
|
+
onUnset={() => unset("headingFont")}
|
|
1445
|
+
>
|
|
1446
|
+
{stateRows("headingFont", Object.values(HeadingFont), headingFontIcon)}
|
|
1447
|
+
</IconGroup>
|
|
1448
|
+
<IconGroup
|
|
1449
|
+
label="Body"
|
|
1450
|
+
options={Object.values(BodyFont)}
|
|
1451
|
+
value={getOverride("bodyFont")}
|
|
1452
|
+
onChange={(v) => set("bodyFont", v)}
|
|
1453
|
+
icon={bodyFontIcon}
|
|
1454
|
+
styles={styles}
|
|
1455
|
+
isDark={isDark}
|
|
1456
|
+
pinned={pinnedKnobs.has("bodyFont")}
|
|
1457
|
+
onTogglePin={() => togglePin("bodyFont")}
|
|
1458
|
+
expanded={expandedKnobs.has("bodyFont")}
|
|
1459
|
+
onToggleExpand={() => toggleExpand("bodyFont")}
|
|
1460
|
+
onUnset={() => unset("bodyFont")}
|
|
1461
|
+
>
|
|
1462
|
+
{stateRows("bodyFont", Object.values(BodyFont), bodyFontIcon)}
|
|
1463
|
+
</IconGroup>
|
|
1464
|
+
<IconGroup
|
|
1465
|
+
label="Weight"
|
|
1466
|
+
options={Object.values(FontWeight)}
|
|
1467
|
+
value={getOverride("fontWeight")}
|
|
1468
|
+
onChange={(v) => set("fontWeight", v)}
|
|
1469
|
+
icon={fontWeightIcon}
|
|
1470
|
+
styles={styles}
|
|
1471
|
+
isDark={isDark}
|
|
1472
|
+
pinned={pinnedKnobs.has("fontWeight")}
|
|
1473
|
+
onTogglePin={() => togglePin("fontWeight")}
|
|
1474
|
+
expanded={expandedKnobs.has("fontWeight")}
|
|
1475
|
+
onToggleExpand={() => toggleExpand("fontWeight")}
|
|
1476
|
+
onUnset={() => unset("fontWeight")}
|
|
1477
|
+
>
|
|
1478
|
+
{stateRows("fontWeight", Object.values(FontWeight), fontWeightIcon)}
|
|
1479
|
+
</IconGroup>
|
|
1480
|
+
<Dropdown
|
|
1481
|
+
label="Animation"
|
|
1482
|
+
options={animationOptions}
|
|
1483
|
+
value={getOverride("animation") ?? ""}
|
|
1484
|
+
onChange={(v) => set("animation", v as Knobs["animation"])}
|
|
1485
|
+
onUnset={() => unset("animation")}
|
|
1486
|
+
styles={styles}
|
|
1487
|
+
pinned={pinnedKnobs.has("animation")}
|
|
1488
|
+
onTogglePin={() => togglePin("animation")}
|
|
1489
|
+
isDark={isDark}
|
|
1490
|
+
/>
|
|
1491
|
+
<div
|
|
1492
|
+
style={{
|
|
1493
|
+
...styles.card,
|
|
1494
|
+
flexDirection: "row" as const,
|
|
1495
|
+
gap: 6,
|
|
1496
|
+
flexWrap: "wrap" as const,
|
|
1497
|
+
}}
|
|
1498
|
+
>
|
|
1499
|
+
<select
|
|
1500
|
+
title="Design system profile"
|
|
1501
|
+
value={activeProfile}
|
|
1502
|
+
onChange={(e) => handleProfileChange(e.target.value)}
|
|
1503
|
+
style={{
|
|
1504
|
+
...styles.select,
|
|
1505
|
+
width: "auto",
|
|
1506
|
+
fontSize: 11,
|
|
1507
|
+
opacity: activeProfile ? 1 : 0.6,
|
|
1508
|
+
}}
|
|
1509
|
+
>
|
|
1510
|
+
{profileNames.map((name) => (
|
|
1511
|
+
<option key={name} value={name}>
|
|
1512
|
+
{name || "profile"}
|
|
1513
|
+
</option>
|
|
1514
|
+
))}
|
|
1515
|
+
</select>
|
|
1516
|
+
<button
|
|
1517
|
+
type="button"
|
|
1518
|
+
style={{
|
|
1519
|
+
...styles.resetBtn,
|
|
1520
|
+
...(copyStatus === "copied"
|
|
1521
|
+
? {
|
|
1522
|
+
background: isDark ? "#1a3a1a" : "#d4edda",
|
|
1523
|
+
borderColor: isDark ? "#2d5a2d" : "#a3d9a5",
|
|
1524
|
+
}
|
|
1525
|
+
: copyStatus === "error"
|
|
1526
|
+
? {
|
|
1527
|
+
background: isDark ? "#3a1a1a" : "#f8d7da",
|
|
1528
|
+
borderColor: isDark ? "#5a2d2d" : "#f5c6cb",
|
|
1529
|
+
}
|
|
1530
|
+
: {}),
|
|
1531
|
+
}}
|
|
1532
|
+
onClick={handleCopySnapshot}
|
|
1533
|
+
>
|
|
1534
|
+
{copyStatus === "copied"
|
|
1535
|
+
? "Copied!"
|
|
1536
|
+
: copyStatus === "error"
|
|
1537
|
+
? "Failed"
|
|
1538
|
+
: "Copy Snapshot"}
|
|
1539
|
+
</button>
|
|
1540
|
+
<button type="button" style={styles.resetBtn} onClick={handleRandomize}>
|
|
1541
|
+
Random
|
|
1542
|
+
</button>
|
|
1543
|
+
<button
|
|
1544
|
+
type="button"
|
|
1545
|
+
style={styles.iconBtn(false)}
|
|
1546
|
+
onClick={handleToggleAllStates}
|
|
1547
|
+
title={allStateKnobsExpanded ? "Collapse States" : "Expand States"}
|
|
1548
|
+
aria-label={allStateKnobsExpanded ? "Collapse States" : "Expand States"}
|
|
1549
|
+
>
|
|
1550
|
+
<svg
|
|
1551
|
+
width="14"
|
|
1552
|
+
height="14"
|
|
1553
|
+
viewBox="0 0 16 16"
|
|
1554
|
+
fill="none"
|
|
1555
|
+
role="img"
|
|
1556
|
+
aria-label="States"
|
|
1557
|
+
>
|
|
1558
|
+
<title>{allStateKnobsExpanded ? "Collapse States" : "Expand States"}</title>
|
|
1559
|
+
<path
|
|
1560
|
+
d="M2.5 4.5h7M2.5 8h7M2.5 11.5h7"
|
|
1561
|
+
stroke="currentColor"
|
|
1562
|
+
strokeWidth="1.3"
|
|
1563
|
+
strokeLinecap="round"
|
|
1564
|
+
/>
|
|
1565
|
+
<path
|
|
1566
|
+
d={allStateKnobsExpanded ? "M12 10.5l-2-2 2-2" : "M10 6.5l2 2-2 2"}
|
|
1567
|
+
stroke="currentColor"
|
|
1568
|
+
strokeWidth="1.3"
|
|
1569
|
+
strokeLinecap="round"
|
|
1570
|
+
strokeLinejoin="round"
|
|
1571
|
+
/>
|
|
1572
|
+
</svg>
|
|
1573
|
+
</button>
|
|
1574
|
+
<button
|
|
1575
|
+
type="button"
|
|
1576
|
+
style={styles.iconBtn(false)}
|
|
1577
|
+
onClick={handleApplyDefaults}
|
|
1578
|
+
title="Apply Defaults"
|
|
1579
|
+
aria-label="Apply Defaults"
|
|
1580
|
+
>
|
|
1581
|
+
<svg
|
|
1582
|
+
width="14"
|
|
1583
|
+
height="14"
|
|
1584
|
+
viewBox="0 0 16 16"
|
|
1585
|
+
fill="none"
|
|
1586
|
+
role="img"
|
|
1587
|
+
aria-label="Defaults"
|
|
1588
|
+
>
|
|
1589
|
+
<title>Apply Defaults</title>
|
|
1590
|
+
<path
|
|
1591
|
+
d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"
|
|
1592
|
+
stroke="currentColor"
|
|
1593
|
+
strokeWidth="1.3"
|
|
1594
|
+
strokeLinecap="round"
|
|
1595
|
+
/>
|
|
1596
|
+
<circle cx="5" cy="4.5" r="1.2" fill="currentColor" />
|
|
1597
|
+
<circle cx="8" cy="8" r="1.2" fill="currentColor" />
|
|
1598
|
+
<circle cx="11" cy="11.5" r="1.2" fill="currentColor" />
|
|
1599
|
+
</svg>
|
|
1600
|
+
</button>
|
|
1601
|
+
<button
|
|
1602
|
+
type="button"
|
|
1603
|
+
style={styles.iconBtn(false)}
|
|
1604
|
+
onClick={handleClearKnobs}
|
|
1605
|
+
title="Clear"
|
|
1606
|
+
aria-label="Clear"
|
|
1607
|
+
>
|
|
1608
|
+
<svg
|
|
1609
|
+
width="14"
|
|
1610
|
+
height="14"
|
|
1611
|
+
viewBox="0 0 16 16"
|
|
1612
|
+
fill="none"
|
|
1613
|
+
role="img"
|
|
1614
|
+
aria-label="Clear"
|
|
1615
|
+
>
|
|
1616
|
+
<title>Clear</title>
|
|
1617
|
+
<path
|
|
1618
|
+
d="M3.5 3.5l9 9M12.5 3.5l-9 9"
|
|
1619
|
+
stroke="currentColor"
|
|
1620
|
+
strokeWidth="1.4"
|
|
1621
|
+
strokeLinecap="round"
|
|
1622
|
+
/>
|
|
1623
|
+
</svg>
|
|
1624
|
+
</button>
|
|
1625
|
+
</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
|
|
1628
|
+
{/* ── Color palette ── */}
|
|
1629
|
+
<ColorLineVisualizer
|
|
1630
|
+
knobs={knobs}
|
|
1631
|
+
isDark={isDark}
|
|
1632
|
+
themeColors={extractThemeTokenColors(
|
|
1633
|
+
tamaguiConfig,
|
|
1634
|
+
isDark ? "dark" : "light",
|
|
1635
|
+
currentThemeColor || undefined,
|
|
1636
|
+
)}
|
|
1637
|
+
oppositeThemeColors={extractThemeTokenColors(
|
|
1638
|
+
tamaguiConfig,
|
|
1639
|
+
isDark ? "light" : "dark",
|
|
1640
|
+
currentThemeColor || undefined,
|
|
1641
|
+
)}
|
|
1642
|
+
accentColors={extractThemeTokenColors(
|
|
1643
|
+
tamaguiConfig,
|
|
1644
|
+
isDark ? "dark" : "light",
|
|
1645
|
+
currentThemeColor || undefined,
|
|
1646
|
+
"accent",
|
|
1647
|
+
)}
|
|
1648
|
+
oppositeAccentColors={extractThemeTokenColors(
|
|
1649
|
+
tamaguiConfig,
|
|
1650
|
+
isDark ? "light" : "dark",
|
|
1651
|
+
currentThemeColor || undefined,
|
|
1652
|
+
"accent",
|
|
1653
|
+
)}
|
|
1654
|
+
/>
|
|
1655
|
+
</div>
|
|
1656
|
+
);
|
|
1657
|
+
}
|