@multiplatform.one/theme 7.34.3 → 7.35.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 (280) hide show
  1. package/package.json +20 -5
  2. package/src/audit/constraintAudit.spec.ts +274 -87
  3. package/src/audit/constraintAudit.ts +135 -120
  4. package/src/audit/constraintScope.spec.ts +1 -1
  5. package/src/audit/designTokens.ts +42 -0
  6. package/src/audit/themeMatrix.spec.ts +83 -2
  7. package/src/audit/themeMatrix.ts +33 -1
  8. package/src/figma/figmaTokens.spec.ts +26 -34
  9. package/src/figma/figmaTokens.ts +26 -50
  10. package/src/fonts/createFontLoader.ts +4 -1
  11. package/src/menuRow.ts +4 -1
  12. package/src/theme/FontKnobStyles.tsx +2 -1
  13. package/src/theme/Preset.stories.tsx +0 -1
  14. package/src/theme/Preset.tsx +6 -1
  15. package/src/theme/PresetContext.ts +4 -0
  16. package/src/theme/Surface.spec.tsx +114 -67
  17. package/src/theme/Surface.stories.tsx +8 -25
  18. package/src/theme/Surface.tsx +87 -43
  19. package/src/theme/accentFloor.spec.ts +37 -11
  20. package/src/theme/activateThemeConfig.ts +22 -6
  21. package/src/theme/animations/animations.spec.ts +62 -0
  22. package/src/theme/animations/css.ts +9 -35
  23. package/src/theme/animations/reactNative.ts +98 -25
  24. package/src/theme/boundaryFloor.spec.ts +57 -0
  25. package/src/theme/chartPalette.spec.ts +56 -27
  26. package/src/theme/chartPalette.ts +33 -28
  27. package/src/theme/colorRules.spec.ts +32 -72
  28. package/src/theme/cookies.spec.ts +49 -18
  29. package/src/theme/cookies.ts +36 -7
  30. package/src/theme/cookiesPersistence.spec.ts +5 -5
  31. package/src/theme/createDefaultThemeConfig.spec.ts +182 -20
  32. package/src/theme/createDefaultThemeConfig.ts +34 -28
  33. package/src/theme/createThemes.ts +169 -218
  34. package/src/theme/defaults/accent.ts +3 -35
  35. package/src/theme/defaults/base.ts +14 -53
  36. package/src/theme/defaults/builderOptions.ts +68 -85
  37. package/src/theme/defaults/categoryFonts.ts +10 -0
  38. package/src/theme/defaults/fonts.spec.ts +84 -4
  39. package/src/theme/defaults/fonts.ts +166 -124
  40. package/src/theme/defaults/house.ts +48 -0
  41. package/src/theme/defaults/index.ts +1 -0
  42. package/src/theme/defaults/neutralRamp.spec.ts +52 -47
  43. package/src/theme/devtools/ColorLineVisualizer.tsx +6 -6
  44. package/src/theme/devtools/ThemeDevtoolsPanel.setMarkers.spec.tsx +13 -6
  45. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +14 -77
  46. package/src/theme/devtools/animationOptions.spec.ts +23 -37
  47. package/src/theme/devtools/animationOptions.ts +4 -6
  48. package/src/theme/devtools/buildSnapshot.ts +0 -3
  49. package/src/theme/devtools/knobIcons.tsx +4 -24
  50. package/src/theme/focusState.spec.ts +9 -30
  51. package/src/theme/index.ts +17 -0
  52. package/src/theme/intentFill.spec.ts +42 -0
  53. package/src/theme/knobs.ts +14 -42
  54. package/src/theme/layoutTokens.ts +15 -1
  55. package/src/theme/layoutTokensHooks.spec.tsx +8 -4
  56. package/src/theme/mergedTokens.ts +20 -0
  57. package/src/theme/motion.spec.ts +84 -0
  58. package/src/theme/motion.ts +53 -0
  59. package/src/theme/motionStops.ts +17 -0
  60. package/src/theme/motionTiming.ts +59 -0
  61. package/src/theme/mpo13Proof.spec.ts +43 -46
  62. package/src/theme/mpo19Proof.spec.ts +13 -14
  63. package/src/theme/namedInkFloor.spec.ts +60 -0
  64. package/src/theme/overlaySurfaces.tsx +330 -0
  65. package/src/theme/preset.types.ts +16 -0
  66. package/src/theme/presetPreview.ts +32 -0
  67. package/src/theme/presetRoot.spec.tsx +89 -0
  68. package/src/theme/presetStore.persistence.spec.ts +141 -0
  69. package/src/theme/presetStore.spec.ts +205 -0
  70. package/src/theme/presetStore.ts +286 -0
  71. package/src/theme/presetUserSync.spec.tsx +168 -0
  72. package/src/theme/presetUserSync.ts +47 -0
  73. package/src/theme/presets.spec.ts +22 -15
  74. package/src/theme/presets.ts +22 -10
  75. package/src/theme/radiusClass.spec.ts +60 -41
  76. package/src/theme/radiusClass.ts +17 -4
  77. package/src/theme/radiusClassTable.generated.ts +3 -3
  78. package/src/theme/readableColor.spec.ts +59 -152
  79. package/src/theme/readableColor.ts +34 -174
  80. package/src/theme/recipeInputs.ts +153 -295
  81. package/src/theme/recipes.ts +58 -18
  82. package/src/theme/resolveKnobs.spec.ts +528 -588
  83. package/src/theme/resolveKnobs.ts +250 -325
  84. package/src/theme/scale/ScaleStyles.native.tsx +19 -0
  85. package/src/theme/scale/ScaleStyles.tsx +48 -0
  86. package/src/theme/scale/activeScale.ts +30 -0
  87. package/src/theme/scale/deriveRecipeInputs.ts +110 -0
  88. package/src/theme/scale/generateScale.spec.ts +260 -0
  89. package/src/theme/scale/generateScale.ts +147 -0
  90. package/src/theme/scale/index.ts +54 -0
  91. package/src/theme/scale/installScaleConfig.native.spec.ts +77 -0
  92. package/src/theme/scale/installScaleConfig.native.ts +57 -0
  93. package/src/theme/scale/installScaleConfig.ts +7 -0
  94. package/src/theme/scale/scaleAttribute.ts +21 -0
  95. package/src/theme/scale/scaleCss.probe.spec.tsx +64 -0
  96. package/src/theme/scale/scaleCss.spec.ts +132 -0
  97. package/src/theme/scale/scaleCss.ts +95 -0
  98. package/src/theme/scale/scaleFonts.ts +106 -0
  99. package/src/theme/scale/scaleInputs.ts +322 -0
  100. package/src/theme/scale/themeScales.ts +181 -0
  101. package/src/theme/scale/useActiveScale.spec.tsx +129 -0
  102. package/src/theme/scale/useActiveScale.ts +95 -0
  103. package/src/theme/schemeStore.spec.ts +81 -0
  104. package/src/theme/schemeStore.ts +67 -0
  105. package/src/theme/shared.spec.ts +7 -6
  106. package/src/theme/shared.tsx +37 -3
  107. package/src/theme/sizeLadder.spec.ts +37 -36
  108. package/src/theme/sizeRecipes.spec.ts +179 -85
  109. package/src/theme/sizeRecipes.ts +48 -25
  110. package/src/theme/subsetThemes.spec.ts +9 -3
  111. package/src/theme/switchThumb.spec.ts +69 -0
  112. package/src/theme/syntaxFloor.spec.ts +11 -8
  113. package/src/theme/theme.hydration.native.spec.tsx +51 -0
  114. package/src/theme/theme.native.tsx +59 -21
  115. package/src/theme/theme.palette.spec.tsx +76 -0
  116. package/src/theme/theme.preset.native.spec.tsx +73 -0
  117. package/src/theme/theme.provider.spec.ts +143 -1
  118. package/src/theme/theme.tsx +61 -25
  119. package/src/theme/tileSizes.ts +6 -7
  120. package/src/theme/transitionProps.native.ts +25 -2
  121. package/src/theme/transitionProps.spec.ts +34 -0
  122. package/src/theme/transitionProps.ts +26 -2
  123. package/src/theme/usePresetSwitch.native.spec.tsx +68 -0
  124. package/src/theme/usePresetSwitch.native.ts +32 -0
  125. package/src/theme/usePresetSwitch.spec.tsx +103 -0
  126. package/src/theme/usePresetSwitch.ts +54 -0
  127. package/src/theme/useResolvedKnobs.hydration.spec.tsx +2 -2
  128. package/src/theme/useResolvedKnobs.ts +23 -90
  129. package/src/theme/useResolvedKnobsBehavior.spec.tsx +25 -30
  130. package/src/theme/useSchemePreference.native.ts +33 -0
  131. package/src/theme/useSchemePreference.spec.tsx +51 -0
  132. package/src/theme/useSchemePreference.ts +32 -0
  133. package/src/theme/useSchemePreference.web.spec.tsx +32 -0
  134. package/src/theme/useTheme.native.spec.tsx +68 -0
  135. package/src/theme/useTheme.native.ts +49 -24
  136. package/src/theme/useTheme.ts +7 -4
  137. package/types/audit/constraintAudit.d.ts +6 -5
  138. package/types/audit/constraintAudit.d.ts.map +1 -1
  139. package/types/audit/designTokens.d.ts +13 -0
  140. package/types/audit/designTokens.d.ts.map +1 -0
  141. package/types/audit/themeMatrix.d.ts +10 -0
  142. package/types/audit/themeMatrix.d.ts.map +1 -1
  143. package/types/figma/figmaTokens.d.ts +11 -28
  144. package/types/figma/figmaTokens.d.ts.map +1 -1
  145. package/types/fonts/createFontLoader.d.ts.map +1 -1
  146. package/types/menuRow.d.ts +4 -1
  147. package/types/menuRow.d.ts.map +1 -1
  148. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  149. package/types/theme/Preset.d.ts.map +1 -1
  150. package/types/theme/PresetContext.d.ts +4 -0
  151. package/types/theme/PresetContext.d.ts.map +1 -1
  152. package/types/theme/Surface.d.ts +57 -26
  153. package/types/theme/Surface.d.ts.map +1 -1
  154. package/types/theme/activateThemeConfig.d.ts +5 -3
  155. package/types/theme/activateThemeConfig.d.ts.map +1 -1
  156. package/types/theme/animations/css.d.ts +27 -34
  157. package/types/theme/animations/css.d.ts.map +1 -1
  158. package/types/theme/animations/reactNative.d.ts +196 -45
  159. package/types/theme/animations/reactNative.d.ts.map +1 -1
  160. package/types/theme/chartPalette.d.ts +11 -3
  161. package/types/theme/chartPalette.d.ts.map +1 -1
  162. package/types/theme/colorRules.d.ts +1 -1
  163. package/types/theme/cookies.d.ts +12 -1
  164. package/types/theme/cookies.d.ts.map +1 -1
  165. package/types/theme/createDefaultThemeConfig.d.ts +13 -13
  166. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  167. package/types/theme/createThemes.d.ts +58 -2
  168. package/types/theme/createThemes.d.ts.map +1 -1
  169. package/types/theme/defaults/accent.d.ts.map +1 -1
  170. package/types/theme/defaults/base.d.ts.map +1 -1
  171. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  172. package/types/theme/defaults/categoryFonts.d.ts +10 -0
  173. package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
  174. package/types/theme/defaults/fonts.d.ts +330 -30
  175. package/types/theme/defaults/fonts.d.ts.map +1 -1
  176. package/types/theme/defaults/house.d.ts +8 -0
  177. package/types/theme/defaults/house.d.ts.map +1 -0
  178. package/types/theme/defaults/index.d.ts +1 -0
  179. package/types/theme/defaults/index.d.ts.map +1 -1
  180. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +3 -3
  181. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  182. package/types/theme/devtools/animationOptions.d.ts.map +1 -1
  183. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -1
  184. package/types/theme/devtools/knobIcons.d.ts +0 -3
  185. package/types/theme/devtools/knobIcons.d.ts.map +1 -1
  186. package/types/theme/index.d.ts +11 -0
  187. package/types/theme/index.d.ts.map +1 -1
  188. package/types/theme/knobs.d.ts +8 -36
  189. package/types/theme/knobs.d.ts.map +1 -1
  190. package/types/theme/layoutTokens.d.ts +13 -1
  191. package/types/theme/layoutTokens.d.ts.map +1 -1
  192. package/types/theme/mergedTokens.d.ts +11 -0
  193. package/types/theme/mergedTokens.d.ts.map +1 -0
  194. package/types/theme/motion.d.ts +17 -0
  195. package/types/theme/motion.d.ts.map +1 -0
  196. package/types/theme/motionStops.d.ts +3 -0
  197. package/types/theme/motionStops.d.ts.map +1 -0
  198. package/types/theme/motionTiming.d.ts +36 -0
  199. package/types/theme/motionTiming.d.ts.map +1 -0
  200. package/types/theme/overlaySurfaces.d.ts +70 -0
  201. package/types/theme/overlaySurfaces.d.ts.map +1 -0
  202. package/types/theme/preset.types.d.ts +16 -0
  203. package/types/theme/preset.types.d.ts.map +1 -1
  204. package/types/theme/presetPreview.d.ts +14 -0
  205. package/types/theme/presetPreview.d.ts.map +1 -0
  206. package/types/theme/presetStore.d.ts +40 -0
  207. package/types/theme/presetStore.d.ts.map +1 -0
  208. package/types/theme/presetUserSync.d.ts +7 -0
  209. package/types/theme/presetUserSync.d.ts.map +1 -0
  210. package/types/theme/presets.d.ts +12 -4
  211. package/types/theme/presets.d.ts.map +1 -1
  212. package/types/theme/radiusClass.d.ts +5 -2
  213. package/types/theme/radiusClass.d.ts.map +1 -1
  214. package/types/theme/radiusClassTable.generated.d.ts +3 -3
  215. package/types/theme/readableColor.d.ts +11 -49
  216. package/types/theme/readableColor.d.ts.map +1 -1
  217. package/types/theme/recipeInputs.d.ts +39 -70
  218. package/types/theme/recipeInputs.d.ts.map +1 -1
  219. package/types/theme/recipes.d.ts +79 -18
  220. package/types/theme/recipes.d.ts.map +1 -1
  221. package/types/theme/resolveKnobs.d.ts +64 -32
  222. package/types/theme/resolveKnobs.d.ts.map +1 -1
  223. package/types/theme/scale/ScaleStyles.d.ts +18 -0
  224. package/types/theme/scale/ScaleStyles.d.ts.map +1 -0
  225. package/types/theme/scale/ScaleStyles.native.d.ts +10 -0
  226. package/types/theme/scale/ScaleStyles.native.d.ts.map +1 -0
  227. package/types/theme/scale/activeScale.d.ts +13 -0
  228. package/types/theme/scale/activeScale.d.ts.map +1 -0
  229. package/types/theme/scale/deriveRecipeInputs.d.ts +10 -0
  230. package/types/theme/scale/deriveRecipeInputs.d.ts.map +1 -0
  231. package/types/theme/scale/generateScale.d.ts +38 -0
  232. package/types/theme/scale/generateScale.d.ts.map +1 -0
  233. package/types/theme/scale/index.d.ts +17 -0
  234. package/types/theme/scale/index.d.ts.map +1 -0
  235. package/types/theme/scale/installScaleConfig.d.ts +4 -0
  236. package/types/theme/scale/installScaleConfig.d.ts.map +1 -0
  237. package/types/theme/scale/installScaleConfig.native.d.ts +13 -0
  238. package/types/theme/scale/installScaleConfig.native.d.ts.map +1 -0
  239. package/types/theme/scale/scaleAttribute.d.ts +14 -0
  240. package/types/theme/scale/scaleAttribute.d.ts.map +1 -0
  241. package/types/theme/scale/scaleCss.d.ts +25 -0
  242. package/types/theme/scale/scaleCss.d.ts.map +1 -0
  243. package/types/theme/scale/scaleFonts.d.ts +16 -0
  244. package/types/theme/scale/scaleFonts.d.ts.map +1 -0
  245. package/types/theme/scale/scaleInputs.d.ts +66 -0
  246. package/types/theme/scale/scaleInputs.d.ts.map +1 -0
  247. package/types/theme/scale/themeScales.d.ts +52 -0
  248. package/types/theme/scale/themeScales.d.ts.map +1 -0
  249. package/types/theme/scale/useActiveScale.d.ts +31 -0
  250. package/types/theme/scale/useActiveScale.d.ts.map +1 -0
  251. package/types/theme/schemeStore.d.ts +6 -0
  252. package/types/theme/schemeStore.d.ts.map +1 -0
  253. package/types/theme/shared.d.ts +18 -0
  254. package/types/theme/shared.d.ts.map +1 -1
  255. package/types/theme/sizeRecipes.d.ts +20 -15
  256. package/types/theme/sizeRecipes.d.ts.map +1 -1
  257. package/types/theme/theme.d.ts +2 -1
  258. package/types/theme/theme.d.ts.map +1 -1
  259. package/types/theme/theme.native.d.ts +10 -1
  260. package/types/theme/theme.native.d.ts.map +1 -1
  261. package/types/theme/tileSizes.d.ts +6 -7
  262. package/types/theme/tileSizes.d.ts.map +1 -1
  263. package/types/theme/transitionProps.d.ts +3 -1
  264. package/types/theme/transitionProps.d.ts.map +1 -1
  265. package/types/theme/transitionProps.native.d.ts +3 -1
  266. package/types/theme/transitionProps.native.d.ts.map +1 -1
  267. package/types/theme/usePresetSwitch.d.ts +10 -0
  268. package/types/theme/usePresetSwitch.d.ts.map +1 -0
  269. package/types/theme/usePresetSwitch.native.d.ts +10 -0
  270. package/types/theme/usePresetSwitch.native.d.ts.map +1 -0
  271. package/types/theme/useResolvedKnobs.d.ts +13 -13
  272. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  273. package/types/theme/useSchemePreference.d.ts +8 -0
  274. package/types/theme/useSchemePreference.d.ts.map +1 -0
  275. package/types/theme/useSchemePreference.native.d.ts +8 -0
  276. package/types/theme/useSchemePreference.native.d.ts.map +1 -0
  277. package/types/theme/useTheme.d.ts +4 -2
  278. package/types/theme/useTheme.d.ts.map +1 -1
  279. package/types/theme/useTheme.native.d.ts +10 -1
  280. package/types/theme/useTheme.native.d.ts.map +1 -1
@@ -33,15 +33,18 @@ describe("MPO-468: code syntax ink holds Axiom 12 on the code surface", () => {
33
33
  }
34
34
  }
35
35
 
36
- it("dark keeps step 11; light walks green, blue and orange toward step 12", () => {
37
- for (const hue of hues) {
38
- expect(themes.dark[token(hue)]).toBe(hex(themes.dark[`${hue}11`]));
36
+ it("keeps stock ink where it holds and darkens only the failing light hues", () => {
37
+ for (const scheme of ["light", "dark"] as const) {
38
+ const theme = themes[scheme];
39
+ for (const hue of hues) {
40
+ const stock = hex(theme[`${hue}11`]);
41
+ const holds = [theme.color3, theme[`${hue}3`]].every(
42
+ (surface) => ratio(stock, surface) >= 4.5,
43
+ );
44
+ if (holds) expect(theme[token(hue)]).toBe(stock);
45
+ else expect(relativeLuminance(theme[token(hue)])).toBeLessThan(relativeLuminance(stock));
46
+ }
39
47
  }
40
- expect(themes.light.syntaxPurple).toBe(hex(themes.light.purple11));
41
- expect(themes.light.syntaxRed).toBe(hex(themes.light.red11));
42
- expect(themes.light.syntaxGreen).toBe("#207c54");
43
- expect(themes.light.syntaxBlue).toBe("#0d6dc3");
44
- expect(themes.light.syntaxOrange).toBe("#bb4904");
45
48
  });
46
49
 
47
50
  it("the tokens ride exactly the themes that carry the hue ramps", () => {
@@ -0,0 +1,51 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { ThemeProvider } from "./theme.native";
4
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
5
+ import { PresetContext, usePresetContext } from "./PresetContext";
6
+ import { hydrateStoredPreset } from "./presetStore";
7
+ import { boldPreset, defaultPreset } from "./presets";
8
+
9
+ const pending = vi.hoisted(() => ({ finish: undefined as undefined | ((value: string) => void) }));
10
+ vi.mock("@multiplatform.one/store", () => ({
11
+ storage: {
12
+ getItem: (key: string) =>
13
+ key === "mp.preset"
14
+ ? new Promise<string>((resolve) => {
15
+ pending.finish = resolve;
16
+ })
17
+ : null,
18
+ },
19
+ }));
20
+ const config = createDefaultThemeConfig({ presets: { bold: boldPreset } });
21
+ afterEach(cleanup);
22
+
23
+ it("holds first paint for async hydration, but leaves ancestor contexts usable", async () => {
24
+ const observed: string[] = [];
25
+ function Probe() {
26
+ const context = usePresetContext();
27
+ observed.push(context!.preset.knobs.borderRadius);
28
+ return <span data-testid="hydrated">{context!.preset.knobs.borderRadius}</span>;
29
+ }
30
+ const ancestor = render(
31
+ <PresetContext.Provider value={{ preset: defaultPreset }}>
32
+ <ThemeProvider config={config}>
33
+ <span>ancestor</span>
34
+ </ThemeProvider>
35
+ </PresetContext.Provider>,
36
+ );
37
+ expect(screen.getByText("ancestor")).toBeDefined();
38
+ render(
39
+ <ThemeProvider config={config}>
40
+ <Probe />
41
+ </ThemeProvider>,
42
+ );
43
+ expect(screen.queryByTestId("hydrated")).toBeNull();
44
+ await act(async () => {
45
+ pending.finish!("bold");
46
+ await hydrateStoredPreset();
47
+ });
48
+ expect(screen.getByTestId("hydrated").textContent).toBe(boldPreset.knobs.borderRadius);
49
+ expect(observed.every((radius) => radius === boldPreset.knobs.borderRadius)).toBe(true);
50
+ ancestor.unmount();
51
+ });
@@ -1,12 +1,21 @@
1
1
  import { ToastProvider } from "@tamagui/toast";
2
- import { useContext, useMemo } from "react";
3
- import { type CreateTamaguiProps, TamaguiProvider, type TamaguiProviderProps } from "tamagui";
2
+ import { Fragment, useContext, useMemo, useSyncExternalStore } from "react";
3
+ import {
4
+ type CreateTamaguiProps,
5
+ TamaguiProvider,
6
+ type TamaguiProviderProps,
7
+ Theme,
8
+ } from "tamagui";
4
9
  import type { PropsWithChildren } from "react";
5
10
  import { CornerSmoothingStyles } from "./cornerSmoothing";
6
11
  import { FontKnobStyles } from "./FontKnobStyles";
12
+ import type { Knobs } from "./knobs";
7
13
  import { PresetContext, type PresetContextValue } from "./PresetContext";
14
+ import { getStoredPreset, subscribeStoredPreset } from "./presetStore";
8
15
  import { defaultPreset } from "./presets";
9
- import { useTheme } from "./useTheme";
16
+ import { resolveThemeScale, useActiveScale } from "./scale/useActiveScale";
17
+ import { type ThemeConfig, getPreset } from "./shared";
18
+ import { useSchemePreference } from "./useSchemePreference";
10
19
 
11
20
  // ---------------------------------------------------------------------------
12
21
  // ThemeProviderProps (simplified — knob-based theming)
@@ -17,7 +26,7 @@ export interface ThemeProviderProps<
17
26
  > extends PropsWithChildren<Omit<TamaguiProviderProps, "config" | "defaultTheme">> {
18
27
  config?: {
19
28
  tamagui: ReturnType<typeof import("tamagui").createTamagui<T>>;
20
- };
29
+ } & Partial<Pick<ThemeConfig, "scales" | "defaultPreset">>;
21
30
  systemTheme?: "light" | "dark";
22
31
  }
23
32
 
@@ -25,39 +34,65 @@ export interface ThemeProviderProps<
25
34
  // ThemeProvider (simplified — TamaguiProvider + scheme + ToastProvider)
26
35
  // ---------------------------------------------------------------------------
27
36
 
37
+ /**
38
+ * Native resolves token values (`.val`) at render and only theme subscribers
39
+ * re-render on a config change, so a preset whose scale differs remounts the
40
+ * subtree under the provider (keyed on the scale id). That is a settings-level
41
+ * action: local component state under the provider does not survive it.
42
+ * The preset persists in `@multiplatform.one/store` storage (`mp.preset`);
43
+ * first render waits for storage so a relaunch paints the stored scale.
44
+ */
28
45
  export function ThemeProvider<T extends CreateTamaguiProps>({
29
46
  children,
30
47
  config,
31
- systemTheme = "light",
48
+ systemTheme,
32
49
  ...tamaguiProps
33
50
  }: ThemeProviderProps<T>) {
34
- const activeTheme = systemTheme;
51
+ const scheme = useSchemePreference();
52
+ const activeTheme = systemTheme ?? scheme.value;
53
+ const ancestor = useContext(PresetContext);
54
+ const stored = useSyncExternalStore(subscribeStoredPreset, getStoredPreset, getStoredPreset);
55
+ const presetName = (ancestor ? ancestor.presetName : stored.presetName) ?? config?.defaultPreset;
56
+ const scaleId = useActiveScale(config, presetName);
57
+ const scaleConfig = resolveThemeScale(config, presetName)?.tamagui() ?? config?.tamagui;
35
58
 
36
59
  return (
37
60
  <TamaguiProvider
38
61
  {...tamaguiProps}
39
- config={config?.tamagui}
62
+ config={scaleConfig as TamaguiProviderProps["config"]}
40
63
  defaultTheme={activeTheme}
41
64
  disableInjectCSS={false}
42
65
  >
43
- <ToastProvider>
44
- <KnobBridge>{children}</KnobBridge>
45
- </ToastProvider>
66
+ {ancestor || stored.hydrated ? (
67
+ <Fragment key={scaleId}>
68
+ <ToastProvider>
69
+ <KnobBridge presetName={presetName} overrides={stored.overrides} scaleId={scaleId}>
70
+ {children}
71
+ </KnobBridge>
72
+ </ToastProvider>
73
+ </Fragment>
74
+ ) : null}
46
75
  </TamaguiProvider>
47
76
  );
48
77
  }
49
78
 
50
- function KnobBridge({ children }: PropsWithChildren) {
79
+ function KnobBridge({
80
+ children,
81
+ presetName,
82
+ overrides,
83
+ scaleId,
84
+ }: PropsWithChildren<{ presetName?: string; overrides: Partial<Knobs>; scaleId: string }>) {
51
85
  const existingCtx = useContext(PresetContext);
52
- const [knobs] = useTheme();
53
86
 
54
- const presetCtxValue = useMemo<PresetContextValue>(
55
- () => ({
56
- preset: { ...defaultPreset, knobs },
57
- overrides: undefined,
58
- }),
59
- [knobs],
60
- );
87
+ const presetCtxValue = useMemo<PresetContextValue>(() => {
88
+ const base = (presetName ? getPreset(presetName) : undefined) ?? defaultPreset;
89
+ return {
90
+ preset: { ...base, knobs: { ...base.knobs, ...overrides } },
91
+ overrides,
92
+ presetName,
93
+ scaleId,
94
+ };
95
+ }, [presetName, overrides, scaleId]);
61
96
 
62
97
  const tree = (
63
98
  <>
@@ -67,9 +102,12 @@ function KnobBridge({ children }: PropsWithChildren) {
67
102
  </>
68
103
  );
69
104
 
105
+ const theme = (existingCtx ?? presetCtxValue).preset.theme;
106
+ const themedTree = theme ? <Theme name={theme}>{tree}</Theme> : tree;
107
+
70
108
  if (existingCtx) {
71
- return tree;
109
+ return themedTree;
72
110
  }
73
111
 
74
- return <PresetContext.Provider value={presetCtxValue}>{tree}</PresetContext.Provider>;
112
+ return <PresetContext.Provider value={presetCtxValue}>{themedTree}</PresetContext.Provider>;
75
113
  }
@@ -0,0 +1,76 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import Cookies from "universal-cookie";
3
+ import { CookiesProvider } from "react-cookie";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { useTheme, useThemeName } from "tamagui";
6
+ import { ThemeProvider as WebProvider } from "./theme";
7
+ import { ThemeProvider as NativeProvider } from "./theme.native";
8
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
9
+ import { PresetContext } from "./PresetContext";
10
+ import { boldPreset, defaultPreset } from "./presets";
11
+ import { setStoredPreset } from "./presetStore";
12
+ import { usePresetSwitch } from "./usePresetSwitch";
13
+
14
+ const config = createDefaultThemeConfig({ presets: { bold: boldPreset, default: defaultPreset } });
15
+ afterEach(cleanup);
16
+ function Probe() {
17
+ const theme = useTheme();
18
+ return (
19
+ <output data-testid="palette" data-color={String(theme.color5?.val)}>
20
+ {useThemeName()}
21
+ </output>
22
+ );
23
+ }
24
+ function WebSwitch() {
25
+ const picker = usePresetSwitch();
26
+ return <button onClick={() => picker.applyPreset("bold")}>Bold</button>;
27
+ }
28
+ it.each([false, true])(
29
+ "applies selected preset palette to web descendants (ancestor: %s)",
30
+ (ancestor) => {
31
+ render(
32
+ <CookiesProvider cookies={new Cookies({ "mp.preset": ancestor ? "default" : "bold" })}>
33
+ <PresetContext.Provider value={ancestor ? { preset: boldPreset } : null}>
34
+ <WebProvider config={config} systemTheme="light">
35
+ <Probe />
36
+ </WebProvider>
37
+ </PresetContext.Provider>
38
+ </CookiesProvider>,
39
+ );
40
+ expect(screen.getByTestId("palette").textContent).toBe("light_pink");
41
+ expect(screen.getByTestId("palette").getAttribute("data-color")).toBe(
42
+ String(config.tamagui.themes.light_pink.color5.val),
43
+ );
44
+ },
45
+ );
46
+ it.each([false, true])(
47
+ "applies selected preset palette to native descendants (ancestor: %s)",
48
+ (ancestor) => {
49
+ setStoredPreset(ancestor ? "default" : "bold");
50
+ render(
51
+ <PresetContext.Provider value={ancestor ? { preset: boldPreset } : null}>
52
+ <NativeProvider config={config} systemTheme="dark">
53
+ <Probe />
54
+ </NativeProvider>
55
+ </PresetContext.Provider>,
56
+ );
57
+ expect(screen.getByTestId("palette").textContent).toBe("dark_pink");
58
+ expect(screen.getByTestId("palette").getAttribute("data-color")).toBe(
59
+ String(config.tamagui.themes.dark_pink.color5.val),
60
+ );
61
+ },
62
+ );
63
+ it("changes the live palette as well as the preset metadata", () => {
64
+ render(
65
+ <CookiesProvider cookies={new Cookies({})}>
66
+ <WebProvider config={config} systemTheme="light">
67
+ <Probe />
68
+ <WebSwitch />
69
+ </WebProvider>
70
+ </CookiesProvider>,
71
+ );
72
+ const initial = screen.getByTestId("palette").getAttribute("data-color");
73
+ act(() => screen.getByText("Bold").click());
74
+ expect(screen.getByTestId("palette").textContent).toBe("light_pink");
75
+ expect(screen.getByTestId("palette").getAttribute("data-color")).not.toBe(initial);
76
+ });
@@ -0,0 +1,73 @@
1
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { ThemeProvider } from "./theme.native";
4
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
5
+ import { getStoredPreset, setStoredPreset } from "./presetStore";
6
+ import { usePresetContext, PresetContext } from "./PresetContext";
7
+ import { useResolvedKnobs } from "./useResolvedKnobs";
8
+ import { usePresetSwitch } from "./usePresetSwitch.native";
9
+ import { boldPreset, defaultPreset } from "./presets";
10
+
11
+ const backend = vi.hoisted(() => ({ data: new Map<string, string>([["mp.preset", "bold"]]) }));
12
+ vi.mock("@multiplatform.one/store", () => ({
13
+ storage: {
14
+ getItem: (key: string) => backend.data.get(key) ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ backend.data.set(key, value);
17
+ },
18
+ removeItem: (key: string) => {
19
+ backend.data.delete(key);
20
+ },
21
+ },
22
+ }));
23
+ const config = createDefaultThemeConfig({ presets: { bold: boldPreset, default: defaultPreset } });
24
+ afterEach(cleanup);
25
+ function Probe() {
26
+ const context = usePresetContext();
27
+ const { knobProps } = useResolvedKnobs();
28
+ const picker = usePresetSwitch();
29
+ return (
30
+ <button
31
+ data-testid="probe"
32
+ data-radius={String(knobProps.borderRadius.borderRadius)}
33
+ onClick={() => picker.applyPreset("default")}
34
+ >
35
+ {context?.preset.knobs.borderRadius}:{context?.preset.tints.join(",")}
36
+ </button>
37
+ );
38
+ }
39
+ it("paints hydrated preset metadata on first mount and switches resolved knobs live across remount", () => {
40
+ const first = render(
41
+ <ThemeProvider config={config}>
42
+ <Probe />
43
+ </ThemeProvider>,
44
+ );
45
+ expect(screen.getByTestId("probe").textContent).toBe(
46
+ `${boldPreset.knobs.borderRadius}:${boldPreset.tints.join(",")}`,
47
+ );
48
+ const boldRadius = screen.getByTestId("probe").getAttribute("data-radius");
49
+ fireEvent.click(screen.getByTestId("probe"));
50
+ expect(screen.getByTestId("probe").textContent).toBe(
51
+ `${defaultPreset.knobs.borderRadius}:${defaultPreset.tints.join(",")}`,
52
+ );
53
+ expect(screen.getByTestId("probe").getAttribute("data-radius")).not.toBe(boldRadius);
54
+ first.unmount();
55
+ render(
56
+ <ThemeProvider config={config}>
57
+ <Probe />
58
+ </ThemeProvider>,
59
+ );
60
+ expect(getStoredPreset().presetName).toBe("default");
61
+ expect(screen.getByTestId("probe").textContent).toContain(defaultPreset.knobs.borderRadius);
62
+ });
63
+ it("preserves an ancestor preset context", () => {
64
+ act(() => setStoredPreset("default"));
65
+ render(
66
+ <PresetContext.Provider value={{ preset: boldPreset }}>
67
+ <ThemeProvider config={config}>
68
+ <Probe />
69
+ </ThemeProvider>
70
+ </PresetContext.Provider>,
71
+ );
72
+ expect(screen.getByTestId("probe").textContent).toContain(boldPreset.knobs.borderRadius);
73
+ });
@@ -1,7 +1,29 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { describe, expect, it } from "vitest";
4
+ import { act, cleanup, render } from "@testing-library/react";
5
+ import { createElement, useEffect, useState } from "react";
6
+ import { Cookies, CookiesProvider } from "react-cookie";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ vi.mock("./animations/index", () => ({ animations: {} }));
10
+
11
+ const { items } = vi.hoisted(() => ({ items: new Map<string, string>() }));
12
+ vi.mock("@multiplatform.one/store", () => ({
13
+ storage: {
14
+ getItem: (key: string) => items.get(key) ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ items.set(key, value);
17
+ },
18
+ removeItem: (key: string) => {
19
+ items.delete(key);
20
+ },
21
+ },
22
+ }));
23
+
24
+ beforeEach(() => items.clear());
25
+
26
+ import { useResolvedKnobs } from "./useResolvedKnobs";
5
27
 
6
28
  const here = dirname(fileURLToPath(import.meta.url));
7
29
 
@@ -31,3 +53,123 @@ describe("ThemeProvider knob injectors", () => {
31
53
  expect(src).toContain("PresetContext.Provider");
32
54
  });
33
55
  });
56
+
57
+ // ── Runtime preset scale switching ───────────────────────────────────────────
58
+
59
+ describe("ThemeProvider preset scale (web)", () => {
60
+ it("sets <html data-mp-scale> from the mp.preset cookie and keeps mounted state across a switch", async () => {
61
+ const { ThemeProvider } = await import("./theme");
62
+ const { themeConfig, cookies, mounts, values, reset } = await scaleHarness();
63
+ cookies.set("mp.preset", "dense", { path: "/" });
64
+
65
+ const view = render(
66
+ createElement(
67
+ CookiesProvider,
68
+ { cookies },
69
+ createElement(
70
+ ThemeProvider,
71
+ { config: themeConfig, systemTheme: "light" },
72
+ createElement(Probe),
73
+ ),
74
+ ),
75
+ );
76
+ expect(document.documentElement.getAttribute("data-mp-scale")).toBe("dense");
77
+ expect(values.control).toBe(36);
78
+ expect(mounts.count).toBe(1);
79
+ act(() => values.setTyped?.("typed"));
80
+ expect(values.typed).toBe("typed");
81
+ const sheet = document.querySelector('style[data-href="mp-scales"], style[href="mp-scales"]');
82
+ expect(sheet?.textContent).toContain(':root[data-mp-scale="dense"]');
83
+
84
+ act(() => cookies.set("mp.preset", "roomy", { path: "/" }));
85
+ expect(document.documentElement.getAttribute("data-mp-scale")).toBe("roomy");
86
+ expect(values.control).toBe(48);
87
+ expect(mounts.count).toBe(1);
88
+ expect(values.typed).toBe("typed");
89
+
90
+ view.unmount();
91
+ reset();
92
+ });
93
+ });
94
+
95
+ describe("ThemeProvider preset scale (native twin)", () => {
96
+ it("remounts the subtree, keyed on the scale id, when the stored preset changes scale", async () => {
97
+ const { ThemeProvider } = await import("./theme.native");
98
+ const { setStoredPreset } = await import("./presetStore");
99
+ const { themeConfig, mounts, values, reset } = await scaleHarness();
100
+ setStoredPreset(undefined);
101
+
102
+ const view = render(
103
+ createElement(
104
+ ThemeProvider,
105
+ { config: themeConfig, systemTheme: "light" },
106
+ createElement(Probe),
107
+ ),
108
+ );
109
+ expect(values.control).toBe(40);
110
+ expect(mounts.count).toBe(1);
111
+
112
+ act(() => setStoredPreset("dense"));
113
+ expect(values.control).toBe(36);
114
+ expect(mounts.count).toBe(2);
115
+
116
+ act(() => setStoredPreset("hero"));
117
+ expect(values.control).toBe(40);
118
+ expect(mounts.count).toBe(3);
119
+
120
+ act(() => setStoredPreset(""));
121
+ expect(mounts.count).toBe(3);
122
+
123
+ await vi.waitFor(() => expect(items.has("mp.preset")).toBe(false));
124
+ act(() => setStoredPreset("dense"));
125
+ await vi.waitFor(() => expect(items.get("mp.preset")).toBe("dense"));
126
+
127
+ view.unmount();
128
+ act(() => setStoredPreset(undefined));
129
+ reset();
130
+ });
131
+ });
132
+
133
+ const probeState: {
134
+ mounts: { count: number };
135
+ values: { control?: number; typed?: string; setTyped?: (value: string) => void };
136
+ } = { mounts: { count: 0 }, values: {} };
137
+
138
+ function Probe() {
139
+ const [typed, setTyped] = useState("");
140
+ const { knobProps } = useResolvedKnobs();
141
+ useEffect(() => {
142
+ probeState.mounts.count += 1;
143
+ }, []);
144
+ probeState.values.control = knobProps.control.height as number;
145
+ probeState.values.typed = typed;
146
+ probeState.values.setTyped = setTyped;
147
+ return null;
148
+ }
149
+
150
+ async function scaleHarness() {
151
+ const { createDefaultThemeConfig } = await import("./createDefaultThemeConfig");
152
+ const { densePreset, heroPreset, roomyPreset } = await import("./presets");
153
+ const { applyThemeScale } = await import("./scale/useActiveScale");
154
+ const themeConfig = createDefaultThemeConfig({
155
+ presets: { hero: heroPreset, dense: densePreset, roomy: roomyPreset },
156
+ });
157
+ probeState.mounts.count = 0;
158
+ probeState.values = {};
159
+ for (const cookie of document.cookie.split(";")) {
160
+ const name = cookie.split("=")[0]?.trim();
161
+ if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
162
+ }
163
+ return {
164
+ themeConfig,
165
+ cookies: new Cookies(),
166
+ mounts: probeState.mounts,
167
+ values: probeState.values,
168
+ reset: () => {
169
+ cleanup();
170
+ document.documentElement.removeAttribute("data-mp-scale");
171
+ const defaults = themeConfig.scales?.default;
172
+ if (defaults) applyThemeScale(defaults);
173
+ },
174
+ };
175
+ }