@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +201 -0
  3. package/package.json +85 -0
  4. package/src/audit/baselines/home-login.json +171 -0
  5. package/src/audit/baselines/home-login.png +0 -0
  6. package/src/audit/baselines/home-pokemon.json +355 -0
  7. package/src/audit/baselines/home-pokemon.png +0 -0
  8. package/src/audit/baselines/home.json +194 -0
  9. package/src/audit/baselines/home.png +0 -0
  10. package/src/audit/baselines/pokemon.json +251 -0
  11. package/src/audit/baselines/pokemon.png +0 -0
  12. package/src/audit/constraintAudit.ts +874 -0
  13. package/src/audit/diffReport.spec.ts +191 -0
  14. package/src/audit/diffReport.ts +344 -0
  15. package/src/audit/index.ts +10 -0
  16. package/src/font.ts +49 -0
  17. package/src/fonts/createFontLoader.ts +15 -0
  18. package/src/fonts/createFontLoader.web.ts +9 -0
  19. package/src/fonts/index.ts +2 -0
  20. package/src/fonts/types.ts +13 -0
  21. package/src/index.ts +3 -0
  22. package/src/theme/Intent.tsx +30 -0
  23. package/src/theme/Preset.tsx +93 -0
  24. package/src/theme/PresetContext.ts +27 -0
  25. package/src/theme/Tint.tsx +44 -0
  26. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +69 -0
  27. package/src/theme/animations/css.ts +25 -0
  28. package/src/theme/animations/index.ts +1 -0
  29. package/src/theme/animations/index.web.ts +1 -0
  30. package/src/theme/animations/reactNative.ts +54 -0
  31. package/src/theme/colorRules.spec.ts +195 -0
  32. package/src/theme/colorRules.ts +166 -0
  33. package/src/theme/cookies.spec.ts +185 -0
  34. package/src/theme/cookies.ts +209 -0
  35. package/src/theme/createDefaultThemeConfig.ts +86 -0
  36. package/src/theme/createThemes.ts +286 -0
  37. package/src/theme/devtools/ColorLineVisualizer.tsx +261 -0
  38. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +1657 -0
  39. package/src/theme/devtools/buildSnapshot.spec.ts +113 -0
  40. package/src/theme/devtools/buildSnapshot.ts +103 -0
  41. package/src/theme/devtools/knobIcons.tsx +217 -0
  42. package/src/theme/devtools/vizConstants.spec.ts +58 -0
  43. package/src/theme/devtools/vizConstants.ts +28 -0
  44. package/src/theme/index.ts +21 -0
  45. package/src/theme/intent.spec.tsx +276 -0
  46. package/src/theme/intents.ts +22 -0
  47. package/src/theme/knobs.ts +147 -0
  48. package/src/theme/pokemonScreen.spec.ts +109 -0
  49. package/src/theme/preset.spec.tsx +242 -0
  50. package/src/theme/preset.types.ts +22 -0
  51. package/src/theme/presets.spec.ts +252 -0
  52. package/src/theme/presets.ts +49 -0
  53. package/src/theme/recipes.ts +69 -0
  54. package/src/theme/resolveKnobs.spec.ts +571 -0
  55. package/src/theme/resolveKnobs.ts +226 -0
  56. package/src/theme/shared.spec.ts +84 -0
  57. package/src/theme/shared.tsx +131 -0
  58. package/src/theme/theme.native.tsx +65 -0
  59. package/src/theme/theme.tsx +190 -0
  60. package/src/theme/tint.spec.tsx +179 -0
  61. package/src/theme/useColorScale.native.ts +7 -0
  62. package/src/theme/useColorScale.ts +29 -0
  63. package/src/theme/useResolvedKnobs.ts +121 -0
  64. package/src/theme/useTheme.native.ts +49 -0
  65. package/src/theme/useTheme.ts +213 -0
  66. package/tsconfig.json +11 -0
  67. 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
+ }