@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,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,7 @@
1
+ /**
2
+ * Native stub — CSS custom properties aren't available on native.
3
+ * The readability floor will use step-based heuristics instead.
4
+ */
5
+ export function useColorScale(): string[] | null {
6
+ return null;
7
+ }
@@ -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,11 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "composite": false,
6
+ "noEmit": true
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules"],
10
+ "references": []
11
+ }
@@ -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
+ });