@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,179 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { type ReactNode, createElement } from "react";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Mock Tamagui's <Theme> so we can inspect the chosen theme name
|
|
7
|
+
vi.mock("tamagui", () => ({
|
|
8
|
+
Theme: ({ name, children }: { name: string; children: ReactNode }) =>
|
|
9
|
+
createElement("div", { "data-tamagui-theme": name }, children),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import type { ThemeName } from "@tamagui/web";
|
|
13
|
+
import { PresetContext, type PresetContextValue } from "./PresetContext";
|
|
14
|
+
import type { Preset } from "./preset.types";
|
|
15
|
+
import { defaultKnobs } from "./knobs";
|
|
16
|
+
import { Tint, useTintDepth } from "./Tint";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function makePreset(tints: ThemeName[]): Preset {
|
|
23
|
+
return {
|
|
24
|
+
theme: "light" as ThemeName,
|
|
25
|
+
knobs: defaultKnobs,
|
|
26
|
+
intents: {},
|
|
27
|
+
tints,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wrapWithPreset(tints: ThemeName[], children: ReactNode): ReactNode {
|
|
32
|
+
const ctx: PresetContextValue = { preset: makePreset(tints) };
|
|
33
|
+
return createElement(PresetContext.Provider, { value: ctx }, children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderToHtml(node: ReactNode): string {
|
|
37
|
+
return renderToString(node as React.ReactElement);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render a tree and return an array of all `data-tamagui-theme` values. */
|
|
41
|
+
function collectThemes(html: string): string[] {
|
|
42
|
+
return Array.from(html.matchAll(/data-tamagui-theme="([^"]+)"/g), (m) => m[1]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Tests
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe("Tint", () => {
|
|
50
|
+
it("selects tints[1 % length] for a single <Tint>", () => {
|
|
51
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
52
|
+
const tree = wrapWithPreset(
|
|
53
|
+
tints,
|
|
54
|
+
createElement(Tint, null, createElement("span", null, "hello")),
|
|
55
|
+
);
|
|
56
|
+
const html = renderToHtml(tree);
|
|
57
|
+
const themes = collectThemes(html);
|
|
58
|
+
// depth = 0 (parent) + 1 = 1 → tints[1 % 3] = "green"
|
|
59
|
+
expect(themes).toEqual(["green"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("increments depth correctly with nested <Tint>", () => {
|
|
63
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
64
|
+
const tree = wrapWithPreset(
|
|
65
|
+
tints,
|
|
66
|
+
createElement(Tint, null, createElement(Tint, null, createElement("span", null, "inner"))),
|
|
67
|
+
);
|
|
68
|
+
const html = renderToHtml(tree);
|
|
69
|
+
const themes = collectThemes(html);
|
|
70
|
+
// outer: depth=1 → tints[1%3]="green", inner: depth=2 → tints[2%3]="blue"
|
|
71
|
+
expect(themes).toEqual(["green", "blue"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("wraps via modulo when depth exceeds tints.length", () => {
|
|
75
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
76
|
+
// 4 nested Tints: depths 1,2,3,4 → indices 1,2,0,1 → green,blue,red,green
|
|
77
|
+
const tree = wrapWithPreset(
|
|
78
|
+
tints,
|
|
79
|
+
createElement(
|
|
80
|
+
Tint,
|
|
81
|
+
null,
|
|
82
|
+
createElement(
|
|
83
|
+
Tint,
|
|
84
|
+
null,
|
|
85
|
+
createElement(Tint, null, createElement(Tint, null, createElement("span", null, "deep"))),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
const html = renderToHtml(tree);
|
|
90
|
+
const themes = collectThemes(html);
|
|
91
|
+
expect(themes).toEqual(["green", "blue", "red", "green"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("applies alt prop offset", () => {
|
|
95
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
96
|
+
// alt=2 at top level: depth = 0 + 1 + 2 = 3 → tints[3%3] = "red"
|
|
97
|
+
const tree = wrapWithPreset(
|
|
98
|
+
tints,
|
|
99
|
+
createElement(Tint, { alt: 2 }, createElement("span", null, "alt")),
|
|
100
|
+
);
|
|
101
|
+
const html = renderToHtml(tree);
|
|
102
|
+
const themes = collectThemes(html);
|
|
103
|
+
expect(themes).toEqual(["red"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skips Theme wrapper when disable is true", () => {
|
|
107
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
108
|
+
const tree = wrapWithPreset(
|
|
109
|
+
tints,
|
|
110
|
+
createElement(Tint, { disable: true }, createElement("span", null, "no theme")),
|
|
111
|
+
);
|
|
112
|
+
const html = renderToHtml(tree);
|
|
113
|
+
const themes = collectThemes(html);
|
|
114
|
+
expect(themes).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("still increments depth when disabled (so children see correct depth)", () => {
|
|
118
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
119
|
+
// disabled outer (depth=1, no theme wrap), inner (depth=2, wraps with tints[2%3]="blue")
|
|
120
|
+
const tree = wrapWithPreset(
|
|
121
|
+
tints,
|
|
122
|
+
createElement(
|
|
123
|
+
Tint,
|
|
124
|
+
{ disable: true },
|
|
125
|
+
createElement(Tint, null, createElement("span", null, "inner")),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
const html = renderToHtml(tree);
|
|
129
|
+
const themes = collectThemes(html);
|
|
130
|
+
expect(themes).toEqual(["blue"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders children without Theme wrapper when tints array is empty", () => {
|
|
134
|
+
const tree = wrapWithPreset(
|
|
135
|
+
[],
|
|
136
|
+
createElement(Tint, null, createElement("span", null, "empty")),
|
|
137
|
+
);
|
|
138
|
+
const html = renderToHtml(tree);
|
|
139
|
+
const themes = collectThemes(html);
|
|
140
|
+
expect(themes).toEqual([]);
|
|
141
|
+
expect(html).toContain("empty");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("renders children without Theme wrapper when no preset context exists", () => {
|
|
145
|
+
// No PresetContext.Provider wrapper — usePresetTints returns []
|
|
146
|
+
const tree = createElement(Tint, null, createElement("span", null, "no context"));
|
|
147
|
+
const html = renderToHtml(tree);
|
|
148
|
+
const themes = collectThemes(html);
|
|
149
|
+
expect(themes).toEqual([]);
|
|
150
|
+
expect(html).toContain("no context");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("useTintDepth", () => {
|
|
155
|
+
it("returns 0 outside of any <Tint>", () => {
|
|
156
|
+
let depth = -1;
|
|
157
|
+
const Probe = () => {
|
|
158
|
+
depth = useTintDepth();
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
renderToString(createElement(Probe));
|
|
162
|
+
expect(depth).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns current depth inside nested <Tint> components", () => {
|
|
166
|
+
const tints: ThemeName[] = ["red", "green", "blue"];
|
|
167
|
+
let capturedDepth = -1;
|
|
168
|
+
const Probe = () => {
|
|
169
|
+
capturedDepth = useTintDepth();
|
|
170
|
+
return createElement("span", null, "probe");
|
|
171
|
+
};
|
|
172
|
+
const tree = wrapWithPreset(
|
|
173
|
+
tints,
|
|
174
|
+
createElement(Tint, null, createElement(Tint, null, createElement(Probe))),
|
|
175
|
+
);
|
|
176
|
+
renderToHtml(tree);
|
|
177
|
+
expect(capturedDepth).toBe(2);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
function readScale(): string[] | null {
|
|
4
|
+
if (typeof document === "undefined") return null;
|
|
5
|
+
const style = getComputedStyle(document.documentElement);
|
|
6
|
+
const out: string[] = [];
|
|
7
|
+
for (let i = 1; i <= 12; i++) {
|
|
8
|
+
let c = style.getPropertyValue(`--color${i}`).trim();
|
|
9
|
+
if (!c) c = style.getPropertyValue(`--color-${i}`).trim();
|
|
10
|
+
if (!c) return null;
|
|
11
|
+
out.push(c);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the current theme's 12 color values read from CSS custom
|
|
18
|
+
* properties (--color1 … --color12). Returns null on the server or
|
|
19
|
+
* when the variables aren't available.
|
|
20
|
+
*/
|
|
21
|
+
export function useColorScale(): string[] | null {
|
|
22
|
+
const [scale, setScale] = useState<string[] | null>(() => readScale());
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setScale(readScale());
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return scale;
|
|
29
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from "react";
|
|
2
|
+
import type { SizeTokens } from "tamagui";
|
|
3
|
+
import { defaultKnobs, type Knobs } from "./knobs";
|
|
4
|
+
import type { IntentOverride } from "./preset.types";
|
|
5
|
+
import type { KnobPropsOverride, ResolvedKnobs } from "./recipes";
|
|
6
|
+
import { resolveKnobs } from "./resolveKnobs";
|
|
7
|
+
import { usePresetContext } from "./PresetContext";
|
|
8
|
+
import { defaultIntents } from "./intents";
|
|
9
|
+
|
|
10
|
+
export const IntentContext = createContext<string | null>(null);
|
|
11
|
+
|
|
12
|
+
export function useIntentContext(): string | null {
|
|
13
|
+
return useContext(IntentContext);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseResolvedKnobsOptions {
|
|
17
|
+
intent?: string;
|
|
18
|
+
component?: string;
|
|
19
|
+
override?: KnobPropsOverride;
|
|
20
|
+
/** Override the size from knobs */
|
|
21
|
+
size?: SizeTokens;
|
|
22
|
+
/** Use compact sizing (steps size down one level) */
|
|
23
|
+
compact?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Map size tokens to knob size values
|
|
27
|
+
const sizeTokenToKnobSize: Record<string, Knobs["size"]> = {
|
|
28
|
+
$1: "small",
|
|
29
|
+
$2: "small",
|
|
30
|
+
$3: "small",
|
|
31
|
+
$4: "medium",
|
|
32
|
+
$5: "large",
|
|
33
|
+
$6: "large",
|
|
34
|
+
$true: "medium",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Step down size for compact mode
|
|
38
|
+
const compactSizeMap: Record<Knobs["size"], Knobs["size"]> = {
|
|
39
|
+
small: "small",
|
|
40
|
+
medium: "small",
|
|
41
|
+
large: "medium",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Step down space for compact mode
|
|
45
|
+
const compactSpaceMap: Record<Knobs["space"], Knobs["space"]> = {
|
|
46
|
+
small: "small",
|
|
47
|
+
medium: "small",
|
|
48
|
+
large: "medium",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve knobs → { knobProps }.
|
|
53
|
+
*
|
|
54
|
+
* Resolution order:
|
|
55
|
+
* 1. Base knobs from nearest <Preset> context
|
|
56
|
+
* 2. Merge intent-level overrides (if intent provided or from context)
|
|
57
|
+
* 3. Merge intent+component overrides (if both provided)
|
|
58
|
+
* 4. Apply size override if provided
|
|
59
|
+
* 5. Apply compact mode (steps size and space down one level)
|
|
60
|
+
* 6. Call resolveKnobs(mergedKnobs, override)
|
|
61
|
+
*/
|
|
62
|
+
export function useResolvedKnobs(options?: UseResolvedKnobsOptions): ResolvedKnobs {
|
|
63
|
+
const presetCtx = usePresetContext();
|
|
64
|
+
const contextIntent = useIntentContext();
|
|
65
|
+
|
|
66
|
+
const intent = options?.intent ?? contextIntent ?? undefined;
|
|
67
|
+
const component = options?.component;
|
|
68
|
+
const override = options?.override;
|
|
69
|
+
const sizeOverride = options?.size;
|
|
70
|
+
const compact = options?.compact;
|
|
71
|
+
|
|
72
|
+
return useMemo(() => {
|
|
73
|
+
const baseKnobs: Knobs = presetCtx
|
|
74
|
+
? { ...presetCtx.preset.knobs, ...presetCtx.overrides }
|
|
75
|
+
: { ...defaultKnobs };
|
|
76
|
+
|
|
77
|
+
let merged: Knobs = baseKnobs;
|
|
78
|
+
if (intent) {
|
|
79
|
+
const intentOverrides: IntentOverride | undefined =
|
|
80
|
+
presetCtx?.preset.intents[intent as keyof typeof presetCtx.preset.intents] ??
|
|
81
|
+
defaultIntents[intent];
|
|
82
|
+
|
|
83
|
+
if (intentOverrides) {
|
|
84
|
+
const knobOverrides: Partial<Knobs> = {};
|
|
85
|
+
for (const [key, value] of Object.entries(intentOverrides)) {
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
(knobOverrides as Record<string, unknown>)[key] = value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
merged = { ...merged, ...knobOverrides };
|
|
91
|
+
|
|
92
|
+
if (component) {
|
|
93
|
+
const componentOverride = intentOverrides[component];
|
|
94
|
+
if (componentOverride && typeof componentOverride === "object") {
|
|
95
|
+
merged = { ...merged, ...componentOverride };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Apply size override if provided
|
|
102
|
+
if (sizeOverride) {
|
|
103
|
+
const sizeStr = String(sizeOverride);
|
|
104
|
+
const knobSize = sizeTokenToKnobSize[sizeStr];
|
|
105
|
+
if (knobSize) {
|
|
106
|
+
merged = { ...merged, size: knobSize };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Apply compact mode - step down size and space
|
|
111
|
+
if (compact) {
|
|
112
|
+
merged = {
|
|
113
|
+
...merged,
|
|
114
|
+
size: compactSizeMap[merged.size],
|
|
115
|
+
space: compactSpaceMap[merged.space],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return resolveKnobs(merged, override);
|
|
120
|
+
}, [presetCtx, intent, component, override, sizeOverride, compact]);
|
|
121
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { type Knobs, defaultKnobs } from "./knobs";
|
|
3
|
+
import { getPreset } from "./shared";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Scheme type (lives here since it's orthogonal to knobs)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export type ThemeScheme = "system" | "dark" | "light";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// useTheme hook — knob-aware (native variant uses local state only, no cookies)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function useTheme(): [
|
|
16
|
+
Knobs,
|
|
17
|
+
(knobs?: Partial<Knobs>) => void,
|
|
18
|
+
boolean,
|
|
19
|
+
(presetName: string) => void,
|
|
20
|
+
string | undefined,
|
|
21
|
+
] {
|
|
22
|
+
const [localKnobs, setLocalKnobs] = useState<Knobs>(() => ({ ...defaultKnobs }));
|
|
23
|
+
const [activePreset, setActivePreset] = useState<string | undefined>(undefined);
|
|
24
|
+
|
|
25
|
+
const setKnobs = useCallback((partial?: Partial<Knobs>): void => {
|
|
26
|
+
if (!partial) return;
|
|
27
|
+
setLocalKnobs((prev) => {
|
|
28
|
+
let hasChanges = false;
|
|
29
|
+
const updated = { ...prev };
|
|
30
|
+
for (const key of Object.keys(partial) as Array<keyof Knobs>) {
|
|
31
|
+
const newValue = partial[key];
|
|
32
|
+
if (newValue !== undefined && newValue !== prev[key]) {
|
|
33
|
+
(updated as Record<string, unknown>)[key] = newValue;
|
|
34
|
+
hasChanges = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return hasChanges ? updated : prev;
|
|
38
|
+
});
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const setPresetFn = useCallback((newPresetName: string) => {
|
|
42
|
+
const preset = getPreset(newPresetName);
|
|
43
|
+
const knobs = preset?.knobs ?? defaultKnobs;
|
|
44
|
+
setActivePreset(newPresetName);
|
|
45
|
+
setLocalKnobs(knobs);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return [localKnobs, setKnobs, false, setPresetFn, activePreset];
|
|
49
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { useCookies } from "react-cookie";
|
|
3
|
+
import { isWeb } from "@multiplatform.one/platform";
|
|
4
|
+
import {
|
|
5
|
+
cookiePreset,
|
|
6
|
+
computeOverrides,
|
|
7
|
+
getCookieWatchList,
|
|
8
|
+
readOverridesCookie,
|
|
9
|
+
readPresetCookie,
|
|
10
|
+
writeOverridesCookie,
|
|
11
|
+
} from "./cookies";
|
|
12
|
+
import { type Knobs, defaultKnobs } from "./knobs";
|
|
13
|
+
import { getPreset } from "./shared";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Scheme type (lives here since it's orthogonal to knobs)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type ThemeScheme = "system" | "dark" | "light";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// useTheme hook — knob-aware
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hook that returns the current knob values (merged from preset base + cookie
|
|
27
|
+
* overrides), a setter to persist partial knob changes, a boolean indicating
|
|
28
|
+
* whether the initial state came from a persisted cookie, a setter to switch
|
|
29
|
+
* the active preset, and the active preset name.
|
|
30
|
+
*
|
|
31
|
+
* Return type:
|
|
32
|
+
* ```
|
|
33
|
+
* [
|
|
34
|
+
* Knobs, // resolved knobs
|
|
35
|
+
* (knobs?: Partial<Knobs>) => void, // merge partial knob updates
|
|
36
|
+
* boolean, // true if initialized from cookie
|
|
37
|
+
* (presetName: string) => void, // switch preset (resets overrides)
|
|
38
|
+
* string | undefined, // active preset name
|
|
39
|
+
* ]
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* On SSR the preset name is read from `mp.preset` cookie, looked up in the
|
|
43
|
+
* preset registry, and its base knobs are merged with `mp.ov.*` overrides.
|
|
44
|
+
* This produces the same resolved knobs on the server as on the client.
|
|
45
|
+
*/
|
|
46
|
+
export function useTheme(): [
|
|
47
|
+
Knobs,
|
|
48
|
+
(knobs?: Partial<Knobs>) => void,
|
|
49
|
+
boolean,
|
|
50
|
+
(presetName: string) => void,
|
|
51
|
+
string | undefined,
|
|
52
|
+
] {
|
|
53
|
+
const isCookieDisabled =
|
|
54
|
+
typeof process !== "undefined" && process.env?.COOKIE_POLICY?.toLowerCase() === "disabled";
|
|
55
|
+
const watchList = isCookieDisabled ? [] : getCookieWatchList();
|
|
56
|
+
const [cookie, setCookie, removeCookie] = useCookies<string, Record<string, string>>(watchList);
|
|
57
|
+
const themeSavedToCookie = useRef(false);
|
|
58
|
+
const isClient = typeof window !== "undefined";
|
|
59
|
+
|
|
60
|
+
// -------------------------------------------------------------------------
|
|
61
|
+
// Read preset + overrides from cookies
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
const presetName = readPresetCookie(cookie as Record<string, string | undefined>);
|
|
64
|
+
const overrides = readOverridesCookie(cookie as Record<string, string | undefined>);
|
|
65
|
+
|
|
66
|
+
const initializedFromCookieRef = useRef<boolean | null>(null);
|
|
67
|
+
if (initializedFromCookieRef.current === null) {
|
|
68
|
+
initializedFromCookieRef.current = presetName != null && presetName.length > 0;
|
|
69
|
+
}
|
|
70
|
+
const initializedFromCookie = initializedFromCookieRef.current;
|
|
71
|
+
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
// Resolve initial knobs: preset base (looked up by name) + overrides
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
const resolvedPreset = presetName ? getPreset(presetName) : undefined;
|
|
76
|
+
const presetBaseKnobs = resolvedPreset?.knobs ?? defaultKnobs;
|
|
77
|
+
|
|
78
|
+
const [localKnobs, setLocalKnobs] = useState<Knobs>(() => ({
|
|
79
|
+
...presetBaseKnobs,
|
|
80
|
+
...overrides,
|
|
81
|
+
}));
|
|
82
|
+
const knobsRef = useRef(localKnobs);
|
|
83
|
+
knobsRef.current = localKnobs;
|
|
84
|
+
|
|
85
|
+
const [activePreset, setActivePreset] = useState<string | undefined>(presetName || undefined);
|
|
86
|
+
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// Cookie write helper
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
const cookieOptions = isClient
|
|
91
|
+
? {
|
|
92
|
+
path: "/",
|
|
93
|
+
sameSite: "strict" as const,
|
|
94
|
+
secure: isWeb && window.location.protocol === "https:",
|
|
95
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
96
|
+
}
|
|
97
|
+
: {
|
|
98
|
+
path: "/",
|
|
99
|
+
sameSite: "strict" as const,
|
|
100
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const writeCookies = useCallback(
|
|
104
|
+
(pName: string, knobOverrides: Partial<Knobs>) => {
|
|
105
|
+
if (isCookieDisabled || !isClient) return;
|
|
106
|
+
|
|
107
|
+
setCookie(cookiePreset, pName, cookieOptions);
|
|
108
|
+
|
|
109
|
+
const { toSet, toRemove } = writeOverridesCookie(knobOverrides);
|
|
110
|
+
for (const { key, value } of toSet) {
|
|
111
|
+
setCookie(key, value, cookieOptions);
|
|
112
|
+
}
|
|
113
|
+
for (const key of toRemove) {
|
|
114
|
+
try {
|
|
115
|
+
removeCookie(key, { path: "/" });
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore errors removing non-existent cookies
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[setCookie, removeCookie, isCookieDisabled, isClient, cookieOptions],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
// setKnobs — accepts partial knob updates (overrides relative to preset)
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
const setKnobs = useCallback(
|
|
128
|
+
(partial?: Partial<Knobs>): void => {
|
|
129
|
+
if (!partial) return;
|
|
130
|
+
|
|
131
|
+
const current = knobsRef.current;
|
|
132
|
+
let hasChanges = false;
|
|
133
|
+
const updated = { ...current };
|
|
134
|
+
|
|
135
|
+
for (const key of Object.keys(partial) as Array<keyof Knobs>) {
|
|
136
|
+
const newValue = partial[key];
|
|
137
|
+
if (newValue === undefined) continue;
|
|
138
|
+
const oldValue = current[key];
|
|
139
|
+
const changed =
|
|
140
|
+
typeof newValue === "object" && newValue !== null
|
|
141
|
+
? JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
|
142
|
+
: newValue !== oldValue;
|
|
143
|
+
if (changed) {
|
|
144
|
+
(updated as Record<string, unknown>)[key] = newValue;
|
|
145
|
+
hasChanges = true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const shouldSave = hasChanges || !themeSavedToCookie.current;
|
|
150
|
+
if (shouldSave) {
|
|
151
|
+
if (hasChanges) {
|
|
152
|
+
knobsRef.current = updated;
|
|
153
|
+
setLocalKnobs(updated);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!isCookieDisabled && isClient) {
|
|
157
|
+
const base = (activePreset ? getPreset(activePreset)?.knobs : undefined) ?? defaultKnobs;
|
|
158
|
+
const knobOverrides = computeOverrides(base, updated);
|
|
159
|
+
writeCookies(activePreset ?? "", knobOverrides);
|
|
160
|
+
themeSavedToCookie.current = true;
|
|
161
|
+
|
|
162
|
+
if (process.env.NODE_ENV === "development") {
|
|
163
|
+
const size = JSON.stringify(knobOverrides).length;
|
|
164
|
+
if (size > 3072) {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[multiplatform.one/theme] Knob overrides cookie is ${size} chars (>3072). Consider reducing overrides.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
[activePreset, isCookieDisabled, isClient, writeCookies],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// setPreset — switch the active preset (resets overrides to empty)
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
const setPresetFn = useCallback(
|
|
180
|
+
(newPresetName: string) => {
|
|
181
|
+
const preset = getPreset(newPresetName);
|
|
182
|
+
const knobs = preset?.knobs ?? defaultKnobs;
|
|
183
|
+
|
|
184
|
+
setActivePreset(newPresetName);
|
|
185
|
+
setLocalKnobs(knobs);
|
|
186
|
+
|
|
187
|
+
if (!isCookieDisabled && isClient) {
|
|
188
|
+
writeCookies(newPresetName, {});
|
|
189
|
+
themeSavedToCookie.current = true;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[isCookieDisabled, isClient, writeCookies],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Save initial knobs to cookies on first client mount
|
|
196
|
+
const savedOnClient = useRef(false);
|
|
197
|
+
useLayoutEffect(() => {
|
|
198
|
+
if (!isClient || isCookieDisabled || savedOnClient.current) return;
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
try {
|
|
201
|
+
const base = (activePreset ? getPreset(activePreset)?.knobs : undefined) ?? defaultKnobs;
|
|
202
|
+
const knobOverrides = computeOverrides(base, localKnobs);
|
|
203
|
+
writeCookies(activePreset ?? "", knobOverrides);
|
|
204
|
+
savedOnClient.current = true;
|
|
205
|
+
themeSavedToCookie.current = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error("Failed to set initial cookie:", error);
|
|
208
|
+
}
|
|
209
|
+
}, 0);
|
|
210
|
+
}, [isClient, isCookieDisabled, writeCookies, localKnobs, activePreset]);
|
|
211
|
+
|
|
212
|
+
return [localKnobs, setKnobs, initializedFromCookie, setPresetFn, activePreset];
|
|
213
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createVitestConfig } from "@multiplatform.one/config/vitest";
|
|
2
|
+
|
|
3
|
+
export default createVitestConfig({
|
|
4
|
+
tamaguiConfig: false,
|
|
5
|
+
environment: "happy-dom",
|
|
6
|
+
replaceReactNative: false,
|
|
7
|
+
include: ["src/**/*.spec.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
|
8
|
+
exclude: ["node_modules", "dist"],
|
|
9
|
+
});
|