@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,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
|
+
}
|