@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,226 @@
1
+ import type { TransitionProp } from "tamagui";
2
+ import type { Knobs, StateKnobs } from "./knobs";
3
+ import type {
4
+ ControlStateProps,
5
+ ElevationStateProps,
6
+ KnobProps,
7
+ KnobPropsOverride,
8
+ KnobRecipe,
9
+ ResolvedKnobs,
10
+ TextStateProps,
11
+ } from "./recipes";
12
+
13
+ // ── Knob-to-token mapping tables ──────────────────────────────
14
+
15
+ const borderRadiusMap = {
16
+ none: "$0",
17
+ small: "$2",
18
+ medium: "$4",
19
+ large: "$6",
20
+ full: "$12",
21
+ } as const;
22
+
23
+ const outerRadiusMap = {
24
+ none: "$0",
25
+ small: "$2",
26
+ medium: "$3",
27
+ large: "$4",
28
+ full: "$5",
29
+ } as const;
30
+
31
+ const borderWidthMap = {
32
+ none: 0,
33
+ small: 0.5,
34
+ medium: 1,
35
+ large: 2,
36
+ } as const;
37
+
38
+ const panelPaddingMap = {
39
+ small: "$2",
40
+ medium: "$3",
41
+ large: "$4",
42
+ } as const;
43
+
44
+ const gapMap = {
45
+ small: "$2",
46
+ medium: "$3",
47
+ large: "$4",
48
+ } as const;
49
+
50
+ const gapLgMap = {
51
+ small: "$3",
52
+ medium: "$4",
53
+ large: "$5",
54
+ } as const;
55
+
56
+ const sizeTokenMap = {
57
+ small: "$3",
58
+ medium: "$4",
59
+ large: "$5",
60
+ } as const;
61
+
62
+ const headingFontMap = {
63
+ "sans-serif": "$heading",
64
+ serif: "$serif",
65
+ mono: "$mono",
66
+ slab: "$slab",
67
+ rounded: "$rounded",
68
+ condensed: "$condensed",
69
+ cursive: "$cursive",
70
+ handwriting: "$handwriting",
71
+ pixel: "$pixel",
72
+ blackletter: "$blackletter",
73
+ geometric: "$geometric",
74
+ } as const;
75
+
76
+ const bodyFontMap = {
77
+ "sans-serif": "$body",
78
+ serif: "$serif",
79
+ mono: "$mono",
80
+ slab: "$slab",
81
+ rounded: "$rounded",
82
+ condensed: "$condensed",
83
+ cursive: "$cursive",
84
+ handwriting: "$handwriting",
85
+ pixel: "$pixel",
86
+ blackletter: "$blackletter",
87
+ geometric: "$geometric",
88
+ } as const;
89
+
90
+ const fontWeightMap = {
91
+ regular: "400",
92
+ bold: "700",
93
+ } as const;
94
+
95
+ const elevationMap: Record<string, string | undefined> = {
96
+ none: undefined,
97
+ small: "$1",
98
+ medium: "$2",
99
+ large: "$4",
100
+ };
101
+
102
+ const textAccentColorMap: Record<string, string> = {
103
+ low: "$color9",
104
+ medium: "$color11",
105
+ high: "$color",
106
+ };
107
+
108
+ // ── State style resolver ──────────────────────────────────────
109
+
110
+ function resolveStateKnobs(state: StateKnobs): {
111
+ control: ControlStateProps;
112
+ text: TextStateProps;
113
+ elevation: ElevationStateProps;
114
+ } {
115
+ const control: ControlStateProps = {};
116
+ const text: TextStateProps = {};
117
+ const elevation: ElevationStateProps = {};
118
+
119
+ if (state.fillStyle !== undefined) {
120
+ control.backgroundColor = state.fillStyle === "outlined" ? "transparent" : "$backgroundPress";
121
+ }
122
+ if (state.borderWidth !== undefined) {
123
+ control.borderWidth = borderWidthMap[state.borderWidth];
124
+ }
125
+ if (state.borderRadius !== undefined) {
126
+ control.borderRadius = borderRadiusMap[state.borderRadius];
127
+ }
128
+ if (state.space !== undefined) {
129
+ control.padding = gapMap[state.space];
130
+ }
131
+ if (state.elevation !== undefined) {
132
+ elevation.elevation = elevationMap[state.elevation];
133
+ }
134
+ if (state.textAccent !== undefined) {
135
+ text.color = textAccentColorMap[state.textAccent];
136
+ }
137
+ if (state.bodyFont !== undefined) {
138
+ text.fontFamily = bodyFontMap[state.bodyFont];
139
+ } else if (state.headingFont !== undefined) {
140
+ text.fontFamily = headingFontMap[state.headingFont];
141
+ }
142
+ if (state.fontWeight !== undefined) {
143
+ text.fontWeight = fontWeightMap[state.fontWeight];
144
+ }
145
+
146
+ return { control, text, elevation };
147
+ }
148
+
149
+ // ── Resolver ──────────────────────────────────────────────────
150
+
151
+ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): ResolvedKnobs {
152
+ const radiusToken = borderRadiusMap[knobs.borderRadius];
153
+ const outerRadiusToken = outerRadiusMap[knobs.borderRadius];
154
+ const borderWidthValue = borderWidthMap[knobs.borderWidth];
155
+ const sizeToken = sizeTokenMap[knobs.size];
156
+ const paddingToken = panelPaddingMap[knobs.space];
157
+ const gapToken = gapMap[knobs.space];
158
+ const fontWeightValue = fontWeightMap[knobs.fontWeight];
159
+ const bodyFontValue = bodyFontMap[knobs.bodyFont] ?? "$body";
160
+ const transition = knobs.animation === "none" ? undefined : (knobs.animation as TransitionProp);
161
+
162
+ const outlined = knobs.fillStyle === "outlined";
163
+ const elevationToken = elevationMap[knobs.elevation];
164
+ const textAccentColor = textAccentColorMap[knobs.textAccent] ?? "$color";
165
+ const inputBackground = outlined ? "transparent" : "$color6";
166
+
167
+ const hover = resolveStateKnobs(knobs.hover);
168
+ const press = resolveStateKnobs(knobs.press);
169
+ const focus = resolveStateKnobs(knobs.focus);
170
+ const focusVisible = resolveStateKnobs(knobs.focusVisible);
171
+
172
+ const nonEmpty = <T extends object>(obj: T): T | undefined =>
173
+ Object.keys(obj).length > 0 ? obj : undefined;
174
+
175
+ const control: KnobRecipe<ControlStateProps> = {
176
+ hoverKnobProps: nonEmpty(hover.control),
177
+ pressKnobProps: nonEmpty(press.control),
178
+ focusKnobProps: nonEmpty(focus.control),
179
+ focusVisibleKnobProps: nonEmpty(focusVisible.control),
180
+ };
181
+ const text: KnobRecipe<TextStateProps> = {
182
+ hoverKnobProps: nonEmpty(hover.text),
183
+ pressKnobProps: nonEmpty(press.text),
184
+ focusKnobProps: nonEmpty(focus.text),
185
+ focusVisibleKnobProps: nonEmpty(focusVisible.text),
186
+ };
187
+ const elevation: KnobRecipe<ElevationStateProps> = {
188
+ hoverKnobProps: nonEmpty(hover.elevation),
189
+ pressKnobProps: nonEmpty(press.elevation),
190
+ focusKnobProps: nonEmpty(focus.elevation),
191
+ focusVisibleKnobProps: nonEmpty(focusVisible.elevation),
192
+ };
193
+
194
+ const knobProps: KnobProps = {
195
+ borderRadius: { borderRadius: radiusToken, borderWidth: borderWidthValue },
196
+ borderRadiusOuter: { borderRadius: outerRadiusToken },
197
+ elevation: elevationToken,
198
+ surface: { borderWidth: borderWidthValue },
199
+ inputSurface: { borderWidth: borderWidthValue },
200
+ panelPadding: { padding: paddingToken },
201
+ gap: { gap: gapToken },
202
+ gapLg: { gap: gapLgMap[knobs.space] },
203
+ sizeToken,
204
+ heading: {
205
+ fontFamily: headingFontMap[knobs.headingFont],
206
+ fontWeight: fontWeightValue,
207
+ },
208
+ body: {
209
+ fontFamily: bodyFontValue,
210
+ fontWeight: fontWeightValue,
211
+ },
212
+ textWeight: { fontWeight: fontWeightValue },
213
+ transition,
214
+ outlined,
215
+ pointy: knobs.borderRadius === "none",
216
+ textAccent: knobs.textAccent,
217
+ textAccentColor,
218
+ inputBackground,
219
+ space: knobs.space,
220
+ size: knobs.size,
221
+ };
222
+
223
+ const finalKnobProps = override ? { ...knobProps, ...override(knobProps, knobs) } : knobProps;
224
+
225
+ return { knobProps: finalKnobProps, control, text, elevation };
226
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import { defaultKnobs, type Knobs } from "./knobs";
3
+ import type { Preset } from "./preset.types";
4
+ import {
5
+ registerPreset,
6
+ getPreset,
7
+ getPresetNames,
8
+ clearPresets,
9
+ resolveThemeFromPreset,
10
+ } from "./shared";
11
+
12
+ const testPreset: Preset = {
13
+ theme: "blue" as any,
14
+ knobs: { ...defaultKnobs, fillStyle: "outlined" },
15
+ intents: {},
16
+ tints: [],
17
+ };
18
+
19
+ describe("preset registry", () => {
20
+ beforeEach(() => {
21
+ clearPresets();
22
+ });
23
+
24
+ it("registers and retrieves a preset", () => {
25
+ registerPreset("test", testPreset);
26
+ expect(getPreset("test")).toBe(testPreset);
27
+ });
28
+
29
+ it("returns undefined for unregistered preset", () => {
30
+ expect(getPreset("nonexistent")).toBeUndefined();
31
+ });
32
+
33
+ it("lists registered preset names", () => {
34
+ registerPreset("alpha", testPreset);
35
+ registerPreset("beta", testPreset);
36
+ expect(getPresetNames()).toEqual(["alpha", "beta"]);
37
+ });
38
+
39
+ it("clears all presets", () => {
40
+ registerPreset("first", testPreset);
41
+ clearPresets();
42
+ expect(getPresetNames()).toEqual([]);
43
+ expect(getPreset("first")).toBeUndefined();
44
+ });
45
+
46
+ it("overwrites a preset with the same name", () => {
47
+ const updated = { ...testPreset, theme: "green" as any };
48
+ registerPreset("test", testPreset);
49
+ registerPreset("test", updated);
50
+ expect(getPreset("test")?.theme).toBe("green");
51
+ });
52
+ });
53
+
54
+ describe("resolveThemeFromPreset", () => {
55
+ beforeEach(() => {
56
+ clearPresets();
57
+ });
58
+
59
+ it("resolves a registered preset with no overrides", () => {
60
+ registerPreset("myPreset", testPreset);
61
+ const resolved = resolveThemeFromPreset("myPreset", {});
62
+ expect(resolved.knobs.fillStyle).toBe("outlined");
63
+ expect(resolved.theme).toBe("blue");
64
+ });
65
+
66
+ it("merges overrides on top of preset knobs", () => {
67
+ registerPreset("myPreset", testPreset);
68
+ const resolved = resolveThemeFromPreset("myPreset", { size: "large" });
69
+ expect(resolved.knobs.size).toBe("large");
70
+ expect(resolved.knobs.fillStyle).toBe("outlined");
71
+ });
72
+
73
+ it("falls back to defaultKnobs for unknown preset", () => {
74
+ const resolved = resolveThemeFromPreset("unknown", { size: "small" });
75
+ expect(resolved.knobs.size).toBe("small");
76
+ expect(resolved.knobs.fillStyle).toBe(defaultKnobs.fillStyle);
77
+ });
78
+
79
+ it("does not mutate the original preset", () => {
80
+ registerPreset("immutable", testPreset);
81
+ resolveThemeFromPreset("immutable", { size: "large" });
82
+ expect(testPreset.knobs.size).toBe("medium");
83
+ });
84
+ });
@@ -0,0 +1,131 @@
1
+ import type { ThemeName } from "@tamagui/web";
2
+ import { type CreateTamaguiProps, createTamagui } from "tamagui";
3
+ import type { Knobs } from "./knobs";
4
+ import { defaultKnobs } from "./knobs";
5
+ import type { Preset } from "./preset.types";
6
+
7
+ // ── Preset registry ───────────────────────────────────────────────────────
8
+
9
+ const presetRegistry = new Map<string, Preset>();
10
+
11
+ export function registerPreset(name: string, preset: Preset): void {
12
+ presetRegistry.set(name, preset);
13
+ }
14
+
15
+ export function getPreset(name: string): Preset | undefined {
16
+ return presetRegistry.get(name);
17
+ }
18
+
19
+ export function getPresetNames(): string[] {
20
+ return Array.from(presetRegistry.keys());
21
+ }
22
+
23
+ export function clearPresets(): void {
24
+ presetRegistry.clear();
25
+ }
26
+
27
+ // ── Preset resolution ─────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Resolve a `Preset` from a registered preset name, merging knob overrides
31
+ * on top of the base preset's knobs.
32
+ *
33
+ * If the preset name is not found, falls back to a default preset shape
34
+ * using `defaultKnobs`.
35
+ */
36
+ export function resolveThemeFromPreset(presetName: string, overrides: Partial<Knobs>): Preset {
37
+ const preset = presetRegistry.get(presetName);
38
+ const base: Preset = preset ?? {
39
+ theme: "" as ThemeName,
40
+ knobs: { ...defaultKnobs },
41
+ intents: {},
42
+ tints: [],
43
+ };
44
+ return {
45
+ ...base,
46
+ knobs: { ...base.knobs, ...overrides },
47
+ };
48
+ }
49
+
50
+ // ── Theme config ──────────────────────────────────────────────────────────
51
+
52
+ export type ThemeConfig<T extends CreateTamaguiProps = CreateTamaguiProps> = {
53
+ tamagui: ReturnType<typeof createTamagui<T>>;
54
+ presets?: Record<string, Preset>;
55
+ defaultPreset?: string;
56
+ /**
57
+ * User-facing color theme names extracted from the raw themes object
58
+ * (e.g. `["blue", "green", "purple", ...]`). Computed at config creation
59
+ * time so consumers don't need to introspect the Tamagui config.
60
+ */
61
+ themeColors: string[];
62
+ };
63
+
64
+ /**
65
+ * Semantic / internal sub-theme names that should never appear in a
66
+ * user-facing "Theme color" picker.
67
+ */
68
+ const excludedThemeNames = new Set([
69
+ "active",
70
+ "alt1",
71
+ "alt2",
72
+ "warning",
73
+ "success",
74
+ "error",
75
+ "accent",
76
+ ]);
77
+
78
+ /**
79
+ * Derive user-facing color names from a raw themes record.
80
+ * A color "foo" is present when both `light_foo` and `dark_foo` exist as keys,
81
+ * the name is all-lowercase, and it isn't a semantic/state sub-theme.
82
+ */
83
+ function extractThemeColors(themes: Record<string, unknown>): string[] {
84
+ const keys = Object.keys(themes);
85
+ const colorSet = new Set<string>();
86
+ for (const key of keys) {
87
+ const match = key.match(/^light_([a-z]+)$/);
88
+ if (match) {
89
+ const color = match[1];
90
+ if (keys.includes(`dark_${color}`) && !excludedThemeNames.has(color)) {
91
+ colorSet.add(color);
92
+ }
93
+ }
94
+ }
95
+ return [...colorSet].sort();
96
+ }
97
+
98
+ export function createThemeConfig<T extends CreateTamaguiProps>(
99
+ tamagui: T,
100
+ options?: {
101
+ presets?: Record<string, Preset>;
102
+ defaultPreset?: string;
103
+ },
104
+ ): ThemeConfig<T> {
105
+ // Extract color names from the raw themes BEFORE createTamagui processes them.
106
+ const themeColors = tamagui.themes
107
+ ? extractThemeColors(tamagui.themes as Record<string, unknown>)
108
+ : [];
109
+
110
+ const tamaguiConfig = createTamagui({
111
+ ...tamagui,
112
+ settings: {
113
+ disableRootThemeClass: false,
114
+ themeClassNameOnRoot: true,
115
+ ...tamagui.settings,
116
+ },
117
+ }) as ReturnType<typeof createTamagui<T>>;
118
+
119
+ if (options?.presets) {
120
+ for (const [name, preset] of Object.entries(options.presets)) {
121
+ registerPreset(name, preset);
122
+ }
123
+ }
124
+
125
+ return {
126
+ tamagui: tamaguiConfig,
127
+ presets: options?.presets,
128
+ defaultPreset: options?.defaultPreset,
129
+ themeColors,
130
+ };
131
+ }
@@ -0,0 +1,65 @@
1
+ import { ToastProvider } from "@tamagui/toast";
2
+ import { useContext, useMemo } from "react";
3
+ import { type CreateTamaguiProps, TamaguiProvider, type TamaguiProviderProps } from "tamagui";
4
+ import type { PropsWithChildren } from "react";
5
+ import { PresetContext, type PresetContextValue } from "./PresetContext";
6
+ import { defaultPreset } from "./presets";
7
+ import { useTheme } from "./useTheme";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ThemeProviderProps (simplified — knob-based theming)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface ThemeProviderProps<
14
+ T extends CreateTamaguiProps = CreateTamaguiProps,
15
+ > extends PropsWithChildren<Omit<TamaguiProviderProps, "config" | "defaultTheme">> {
16
+ config?: {
17
+ tamagui: ReturnType<typeof import("tamagui").createTamagui<T>>;
18
+ };
19
+ systemTheme?: "light" | "dark";
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // ThemeProvider (simplified — TamaguiProvider + scheme + ToastProvider)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function ThemeProvider<T extends CreateTamaguiProps>({
27
+ children,
28
+ config,
29
+ systemTheme = "light",
30
+ ...tamaguiProps
31
+ }: ThemeProviderProps<T>) {
32
+ const activeTheme = systemTheme;
33
+
34
+ return (
35
+ <TamaguiProvider
36
+ {...tamaguiProps}
37
+ config={config?.tamagui}
38
+ defaultTheme={activeTheme}
39
+ disableInjectCSS={false}
40
+ >
41
+ <ToastProvider>
42
+ <KnobBridge>{children}</KnobBridge>
43
+ </ToastProvider>
44
+ </TamaguiProvider>
45
+ );
46
+ }
47
+
48
+ function KnobBridge({ children }: PropsWithChildren) {
49
+ const existingCtx = useContext(PresetContext);
50
+ const [knobs] = useTheme();
51
+
52
+ const presetCtxValue = useMemo<PresetContextValue>(
53
+ () => ({
54
+ preset: { ...defaultPreset, knobs },
55
+ overrides: undefined,
56
+ }),
57
+ [knobs],
58
+ );
59
+
60
+ if (existingCtx) {
61
+ return <>{children}</>;
62
+ }
63
+
64
+ return <PresetContext.Provider value={presetCtxValue}>{children}</PresetContext.Provider>;
65
+ }
@@ -0,0 +1,190 @@
1
+ import { ToastProvider } from "@tamagui/toast";
2
+ import { useContext, useEffect, useInsertionEffect, useMemo, useRef } from "react";
3
+ import { useCookies } from "react-cookie";
4
+ import { type CreateTamaguiProps, TamaguiProvider, type TamaguiProviderProps } from "tamagui";
5
+ import type { PropsWithChildren } from "react";
6
+ import { animationDurations } from "./animations/css";
7
+ import { getCookieWatchList, readOverridesCookie, readPresetCookie } from "./cookies";
8
+ import { PresetContext, type PresetContextValue } from "./PresetContext";
9
+ import { defaultPreset } from "./presets";
10
+ import { getPreset } from "./shared";
11
+
12
+ /**
13
+ * Animate a DOM mutation (typically a theme/scheme switch) using the View
14
+ * Transitions API where available, falling back to a temporary class-gated
15
+ * CSS transition on `<html>`.
16
+ *
17
+ * Both paths derive their duration from the `--mp-transition` CSS variable,
18
+ * which KnobBridge keeps in sync with the animation knob.
19
+ */
20
+ export function startThemeTransition(apply: () => void): void {
21
+ if (typeof document === "undefined") {
22
+ apply();
23
+ return;
24
+ }
25
+
26
+ if (document.startViewTransition) {
27
+ document.startViewTransition(apply);
28
+ return;
29
+ }
30
+
31
+ const root = document.documentElement;
32
+ root.classList.add("mp-transitioning");
33
+ apply();
34
+
35
+ const transitionMs =
36
+ Number.parseInt(getComputedStyle(root).getPropertyValue("--mp-transition"), 10) || 200;
37
+ setTimeout(() => root.classList.remove("mp-transitioning"), transitionMs + 50);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // ThemeProviderProps (simplified — knob-based theming)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface ThemeProviderProps<
45
+ T extends CreateTamaguiProps = CreateTamaguiProps,
46
+ > extends PropsWithChildren<Omit<TamaguiProviderProps, "config" | "defaultTheme">> {
47
+ config?: {
48
+ tamagui: ReturnType<typeof import("tamagui").createTamagui<T>>;
49
+ };
50
+ systemTheme?: "light" | "dark";
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // ThemeProvider (simplified — TamaguiProvider + scheme + ToastProvider)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export function ThemeProvider<T extends CreateTamaguiProps>({
58
+ children,
59
+ config,
60
+ systemTheme = "light",
61
+ ...tamaguiProps
62
+ }: ThemeProviderProps<T>) {
63
+ // The caller is responsible for resolving the active scheme:
64
+ // - App: useUserScheme() from @vxrn/color-scheme (reacts to OS changes)
65
+ // - Storybook: decorator resolves "system"/"light"/"dark" with OS listener
66
+ // ThemeProvider simply applies whatever the caller provides.
67
+ const activeTheme = systemTheme;
68
+
69
+ const prevThemeRef = useRef(activeTheme);
70
+
71
+ // Set the initial theme class synchronously to avoid FOUC.
72
+ useInsertionEffect(() => {
73
+ if (typeof document === "undefined") return;
74
+ const classList = document.documentElement.classList;
75
+ const toAdd = `t_${activeTheme}`;
76
+ if (!classList.contains(toAdd)) {
77
+ classList.remove(activeTheme === "dark" ? "t_light" : "t_dark");
78
+ classList.add(toAdd);
79
+ }
80
+ document.documentElement.style.colorScheme = activeTheme;
81
+ }, [activeTheme]);
82
+
83
+ // Animate subsequent scheme changes via View Transitions / class-gated fallback.
84
+ useEffect(() => {
85
+ if (prevThemeRef.current === activeTheme) return;
86
+ prevThemeRef.current = activeTheme;
87
+
88
+ startThemeTransition(() => {
89
+ const classList = document.documentElement.classList;
90
+ classList.remove(activeTheme === "dark" ? "t_light" : "t_dark");
91
+ classList.add(`t_${activeTheme}`);
92
+ document.documentElement.style.colorScheme = activeTheme;
93
+ });
94
+ }, [activeTheme]);
95
+
96
+ // Inject class-gated CSS fallback (dormant until .mp-transitioning is added)
97
+ // plus View Transition duration tied to the animation knob.
98
+ useInsertionEffect(() => {
99
+ if (typeof document === "undefined") return;
100
+ const id = "__mp-theme-transitions";
101
+ if (document.getElementById(id)) return;
102
+ const style = document.createElement("style");
103
+ style.id = id;
104
+ style.textContent = [
105
+ ":root { --mp-transition: 200ms; }",
106
+ // Reserve space for scrollbar to prevent gap when modal Sheet hides it
107
+ "html { scrollbar-gutter: stable; }",
108
+ // Disable browser-native focus ring on form controls;
109
+ // components provide their own focus-visible affordance.
110
+ "input:focus, input:focus-visible,",
111
+ "textarea:focus, textarea:focus-visible,",
112
+ "select:focus, select:focus-visible,",
113
+ "button:focus, button:focus-visible {",
114
+ " outline: none;",
115
+ " box-shadow: none;",
116
+ "}",
117
+ ".mp-transitioning *, .mp-transitioning *::before, .mp-transitioning *::after {",
118
+ " transition: background-color var(--mp-transition) ease,",
119
+ " color var(--mp-transition) ease,",
120
+ " border-color var(--mp-transition) ease,",
121
+ " box-shadow var(--mp-transition) ease,",
122
+ " outline-color var(--mp-transition) ease;",
123
+ "}",
124
+ "::view-transition-old(root), ::view-transition-new(root) {",
125
+ " animation-duration: calc(var(--mp-transition) * 1);",
126
+ "}",
127
+ ].join("\n");
128
+ document.head.appendChild(style);
129
+ }, []);
130
+
131
+ return (
132
+ <TamaguiProvider
133
+ {...tamaguiProps}
134
+ config={config?.tamagui}
135
+ defaultTheme={activeTheme}
136
+ disableInjectCSS={false}
137
+ >
138
+ <ToastProvider>
139
+ <KnobBridge>{children}</KnobBridge>
140
+ </ToastProvider>
141
+ </TamaguiProvider>
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Internal bridge that reads the preset name + knob overrides from cookies
147
+ * (reactive via CookiesProvider) and provides a PresetContext so that
148
+ * useResolvedKnobs() picks up the live values.
149
+ *
150
+ * Resolution order (same on SSR and client):
151
+ * 1. Read `mp.preset` cookie → look up the preset from the registry
152
+ * 2. Read `mp.ov.*` cookies → partial knob overrides
153
+ * 3. Merge: preset base knobs + overrides → final knobs
154
+ * 4. Provide the full preset (knobs, tints, intents) via PresetContext
155
+ *
156
+ * If an ancestor PresetContext already exists (e.g. from a Storybook decorator),
157
+ * the bridge is a pass-through to avoid overriding it.
158
+ */
159
+ function KnobBridge({ children }: PropsWithChildren) {
160
+ const existingCtx = useContext(PresetContext);
161
+
162
+ const isCookieDisabled =
163
+ typeof process !== "undefined" && process.env?.COOKIE_POLICY?.toLowerCase() === "disabled";
164
+ const watchList = isCookieDisabled ? [] : getCookieWatchList();
165
+ const [cookie] = useCookies<string, Record<string, string>>(watchList);
166
+
167
+ const presetName = readPresetCookie(cookie as Record<string, string | undefined>);
168
+ const overrides = readOverridesCookie(cookie as Record<string, string | undefined>);
169
+
170
+ const presetCtxValue = useMemo<PresetContextValue>(() => {
171
+ const base = (presetName ? getPreset(presetName) : undefined) ?? defaultPreset;
172
+ return {
173
+ preset: { ...base, knobs: { ...base.knobs, ...overrides } },
174
+ overrides,
175
+ };
176
+ }, [presetName, overrides]);
177
+
178
+ const animationValue = presetCtxValue.preset.knobs.animation;
179
+ useEffect(() => {
180
+ if (typeof document === "undefined") return;
181
+ const durationMs = animationValue === "none" ? 0 : (animationDurations[animationValue] ?? 200);
182
+ document.documentElement.style.setProperty("--mp-transition", `${durationMs}ms`);
183
+ }, [animationValue]);
184
+
185
+ if (existingCtx) {
186
+ return <>{children}</>;
187
+ }
188
+
189
+ return <PresetContext.Provider value={presetCtxValue}>{children}</PresetContext.Provider>;
190
+ }