@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
@@ -5,6 +5,7 @@ import {
5
5
  type CreateTamaguiProps,
6
6
  TamaguiProvider,
7
7
  type TamaguiProviderProps,
8
+ Theme,
8
9
  useDidFinishSSR,
9
10
  } from "tamagui";
10
11
  import type { PropsWithChildren } from "react";
@@ -12,9 +13,13 @@ import { animationDurations } from "./animations/css";
12
13
  import { getCookieWatchList, readOverridesCookie, readPresetCookie } from "./cookies";
13
14
  import { CornerSmoothingStyles } from "./cornerSmoothing";
14
15
  import { FontKnobStyles } from "./FontKnobStyles";
16
+ import type { Knobs } from "./knobs";
15
17
  import { PresetContext, type PresetContextValue } from "./PresetContext";
16
18
  import { defaultPreset } from "./presets";
17
- import { getPreset } from "./shared";
19
+ import { ScaleStyles } from "./scale/ScaleStyles";
20
+ import { useActiveScale } from "./scale/useActiveScale";
21
+ import { type ThemeConfig, getPreset } from "./shared";
22
+ import { useSchemePreference } from "./useSchemePreference";
18
23
 
19
24
  /**
20
25
  * Animate a DOM mutation (typically a theme/scheme switch) using the View
@@ -53,10 +58,22 @@ export interface ThemeProviderProps<
53
58
  > extends PropsWithChildren<Omit<TamaguiProviderProps, "config" | "defaultTheme">> {
54
59
  config?: {
55
60
  tamagui: ReturnType<typeof import("tamagui").createTamagui<T>>;
56
- };
61
+ } & Partial<Pick<ThemeConfig, "scales" | "defaultPreset">>;
57
62
  systemTheme?: "light" | "dark";
58
63
  }
59
64
 
65
+ function useCookiePreset(): { presetName?: string; overrides: Partial<Knobs> } {
66
+ const isCookieDisabled =
67
+ typeof process !== "undefined" && process.env?.COOKIE_POLICY?.toLowerCase() === "disabled";
68
+ const watchList = isCookieDisabled ? [] : getCookieWatchList();
69
+ const [cookie] = useCookies<string, Record<string, string>>(watchList);
70
+ const presetName = readPresetCookie(cookie as Record<string, string | undefined>);
71
+ const overrides = readOverridesCookie(cookie as Record<string, string | undefined>);
72
+ const overridesKey = JSON.stringify(overrides);
73
+ const stableOverrides = useMemo(() => JSON.parse(overridesKey) as Partial<Knobs>, [overridesKey]);
74
+ return { presetName, overrides: stableOverrides };
75
+ }
76
+
60
77
  // ---------------------------------------------------------------------------
61
78
  // ThemeProvider (simplified — TamaguiProvider + scheme + ToastProvider)
62
79
  // ---------------------------------------------------------------------------
@@ -64,18 +81,28 @@ export interface ThemeProviderProps<
64
81
  export function ThemeProvider<T extends CreateTamaguiProps>({
65
82
  children,
66
83
  config,
67
- systemTheme = "light",
84
+ systemTheme,
68
85
  ...tamaguiProps
69
86
  }: ThemeProviderProps<T>) {
70
- // The caller is responsible for resolving the active scheme:
87
+ // A caller that resolves the scheme itself wins:
71
88
  // - App: useUserScheme() from @vxrn/color-scheme (reacts to OS changes)
72
89
  // - Storybook: decorator resolves "system"/"light"/"dark" with OS listener
73
- // ThemeProvider simply applies whatever the caller provides.
74
- const activeTheme = systemTheme;
90
+ // Without one, the shared scheme preference (ThemePresetPicker's) applies.
91
+ const scheme = useSchemePreference();
92
+ const activeTheme = systemTheme ?? scheme.value;
75
93
  // A hydrating root renders a light placeholder to match SSR (MPO-278); the
76
94
  // blocking scheme script owns <html> until hydration finishes.
77
95
  const hydrated = useDidFinishSSR();
78
96
 
97
+ // The preset is resolved here, above TamaguiProvider, so its scale is
98
+ // installed before the first child renders (SSR and hydration alike). An
99
+ // ancestor PresetContext (Storybook's decorator) owns the preset instead.
100
+ const ancestor = useContext(PresetContext);
101
+ const cookiePreset = useCookiePreset();
102
+ const presetName =
103
+ (ancestor ? ancestor.presetName : cookiePreset.presetName) ?? config?.defaultPreset;
104
+ const scaleId = useActiveScale(config, presetName);
105
+
79
106
  const prevThemeRef = useRef<string | undefined>(undefined);
80
107
 
81
108
  // Set the initial theme class synchronously to avoid FOUC.
@@ -140,6 +167,9 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
140
167
  document.head.appendChild(style);
141
168
  }, []);
142
169
 
170
+ // The base config, not the scale's: TamaguiProvider's stylesheet is keyed
171
+ // by href and never rewritten after first render, so it must hold the
172
+ // default token values the ScaleStyles blocks override.
143
173
  return (
144
174
  <TamaguiProvider
145
175
  {...tamaguiProps}
@@ -147,23 +177,27 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
147
177
  defaultTheme={activeTheme}
148
178
  disableInjectCSS={false}
149
179
  >
180
+ <ScaleStyles config={config} presetName={presetName} />
150
181
  <ToastProvider>
151
- <KnobBridge>{children}</KnobBridge>
182
+ <KnobBridge presetName={presetName} overrides={cookiePreset.overrides} scaleId={scaleId}>
183
+ {children}
184
+ </KnobBridge>
152
185
  </ToastProvider>
153
186
  </TamaguiProvider>
154
187
  );
155
188
  }
156
189
 
157
190
  /**
158
- * Internal bridge that reads the preset name + knob overrides from cookies
159
- * (reactive via CookiesProvider) and provides a PresetContext so that
160
- * useResolvedKnobs() picks up the live values.
191
+ * Internal bridge that provides a PresetContext from the preset name + knob
192
+ * overrides ThemeProvider read from cookies (reactive via CookiesProvider),
193
+ * so that useResolvedKnobs() picks up the live values.
161
194
  *
162
195
  * Resolution order (same on SSR and client):
163
- * 1. Read `mp.preset` cookie → look up the preset from the registry
164
- * 2. Read `mp.ov.*` cookies → partial knob overrides
196
+ * 1. `mp.preset` cookie → look up the preset from the registry
197
+ * 2. `mp.ov.*` cookies → partial knob overrides
165
198
  * 3. Merge: preset base knobs + overrides → final knobs
166
- * 4. Provide the full preset (knobs, tints, intents) via PresetContext
199
+ * 4. Provide the full preset (knobs, tints, intents) plus the active
200
+ * preset name and scale id via PresetContext
167
201
  *
168
202
  * If an ancestor PresetContext already exists (e.g. from a Storybook decorator),
169
203
  * the bridge is a pass-through to avoid overriding it.
@@ -173,24 +207,23 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
173
207
  * stylesheet restyle every shipped app that uses ThemeProvider. Hairline
174
208
  * still self-mounts on module load.
175
209
  */
176
- function KnobBridge({ children }: PropsWithChildren) {
210
+ function KnobBridge({
211
+ children,
212
+ presetName,
213
+ overrides,
214
+ scaleId,
215
+ }: PropsWithChildren<{ presetName?: string; overrides: Partial<Knobs>; scaleId: string }>) {
177
216
  const existingCtx = useContext(PresetContext);
178
217
 
179
- const isCookieDisabled =
180
- typeof process !== "undefined" && process.env?.COOKIE_POLICY?.toLowerCase() === "disabled";
181
- const watchList = isCookieDisabled ? [] : getCookieWatchList();
182
- const [cookie] = useCookies<string, Record<string, string>>(watchList);
183
-
184
- const presetName = readPresetCookie(cookie as Record<string, string | undefined>);
185
- const overrides = readOverridesCookie(cookie as Record<string, string | undefined>);
186
-
187
218
  const presetCtxValue = useMemo<PresetContextValue>(() => {
188
219
  const base = (presetName ? getPreset(presetName) : undefined) ?? defaultPreset;
189
220
  return {
190
221
  preset: { ...base, knobs: { ...base.knobs, ...overrides } },
191
222
  overrides,
223
+ presetName,
224
+ scaleId,
192
225
  };
193
- }, [presetName, overrides]);
226
+ }, [presetName, overrides, scaleId]);
194
227
 
195
228
  const animationValue = presetCtxValue.preset.knobs.animation;
196
229
  useEffect(() => {
@@ -209,9 +242,12 @@ function KnobBridge({ children }: PropsWithChildren) {
209
242
  </>
210
243
  );
211
244
 
245
+ const theme = (existingCtx ?? presetCtxValue).preset.theme;
246
+ const themedTree = theme ? <Theme name={theme}>{tree}</Theme> : tree;
247
+
212
248
  if (existingCtx) {
213
- return tree;
249
+ return themedTree;
214
250
  }
215
251
 
216
- return <PresetContext.Provider value={presetCtxValue}>{tree}</PresetContext.Provider>;
252
+ return <PresetContext.Provider value={presetCtxValue}>{themedTree}</PresetContext.Provider>;
217
253
  }
@@ -30,13 +30,12 @@
30
30
  * here, restyled only through recipe inputs
31
31
  * (`setSizeRecipeInputs({ tile })` / `createDefaultThemeConfig({ recipeInputs:
32
32
  * { tile } })`). WHICH rung a surface shows is per-app (a prop, often
33
- * persisted per user). Tile gutters stay on the space knob and tile rounding
34
- * on the borderRadius knob — this module never emits a gap or a radius.
33
+ * persisted per user). Tile gutters are the spacing roles and tile rounding
34
+ * the borderRadius knob — this module never emits a gap or a radius.
35
35
  *
36
- * DENSITY (LC-76): density moves SPACE only. A compact density steps the
37
- * gutters between tiles (via the space knob) and must never re-rung the
38
- * ladder — tile magnitude is content scale, not chrome scale, so nothing
39
- * here reads the density knob.
36
+ * SCALE (LC-100): a preset's token scale moves the gutters (role px) and
37
+ * never re-rungs the ladder — tile magnitude is content scale, not chrome
38
+ * scale.
40
39
  */
41
40
 
42
41
  // ── Rungs ─────────────────────────────────────────────────────
@@ -239,7 +238,7 @@ export function tileRungFlexProps(stop: TileSizeStop): TileRungFlexProps {
239
238
  /**
240
239
  * Column count for a fixed-column tile grid (the ImageGrid / springboard
241
240
  * idiom): as many columns as fit at the rung's minWidth. `gutter` is the
242
- * inter-column gap (the space knob's value), never baked into the rung.
241
+ * inter-column gap (the gap role's px), never baked into the rung.
243
242
  *
244
243
  * The count is min-driven on purpose: adding a column to honor `maxWidth`
245
244
  * would, by construction of the fit, drop every cell below `minWidth`
@@ -1,3 +1,4 @@
1
+ import type { MotionRole } from "./motion";
1
2
  import type { TransitionProp } from "tamagui";
2
3
 
3
4
  /**
@@ -8,8 +9,30 @@ import type { TransitionProp } from "tamagui";
8
9
  * animation `none` would put motion on every part that is meant to be still.
9
10
  * Native keeps omitting it until the driver has an instant token to pass.
10
11
  */
11
- export function transitionProps(transition: TransitionProp | undefined): {
12
+ export function transitionProps(
13
+ role: MotionRole | TransitionProp | undefined,
14
+ extra: readonly string[] = [],
15
+ ): {
12
16
  transition?: TransitionProp;
17
+ animateOnly?: string[];
13
18
  } {
14
- return transition ? { transition } : {};
19
+ const isRole = isMotionRole(role);
20
+ const transition = isRole ? role.transition : role;
21
+ if (transition === undefined) return {};
22
+ if (!isRole) return { transition };
23
+ const properties = [...role.animateOnly, ...extra];
24
+ return {
25
+ transition,
26
+ animateOnly: properties,
27
+ };
28
+ }
29
+
30
+ function isMotionRole(value: MotionRole | TransitionProp | undefined): value is MotionRole {
31
+ return (
32
+ typeof value === "object" &&
33
+ value !== null &&
34
+ "transition" in value &&
35
+ "animateOnly" in value &&
36
+ Array.isArray(value.animateOnly)
37
+ );
15
38
  }
@@ -1,3 +1,5 @@
1
+ import { motionFor } from "./motion";
2
+ import { transitionProps as nativeTransitionProps } from "./transitionProps.native";
1
3
  import { describe, expect, it } from "vitest";
2
4
  import { transitionProps } from "./transitionProps";
3
5
 
@@ -12,3 +14,35 @@ describe("transitionProps (web)", () => {
12
14
  expect(transitionProps("bouncy")).toEqual({ transition: "bouncy" });
13
15
  });
14
16
  });
17
+
18
+ describe("role property scopes", () => {
19
+ it("keeps the web transition key for a disabled role", () => {
20
+ expect(transitionProps(motionFor("none").control)).toEqual({
21
+ transition: undefined,
22
+ animateOnly: ["background-color", "border-color", "color", "opacity", "transform"],
23
+ });
24
+ });
25
+ it("hyphenates the control scope and additional properties", () => {
26
+ expect(transitionProps(motionFor("quick").control, ["maxHeight", "left"])).toEqual({
27
+ transition: "quick",
28
+ animateOnly: [
29
+ "background-color",
30
+ "border-color",
31
+ "color",
32
+ "opacity",
33
+ "transform",
34
+ "max-height",
35
+ "left",
36
+ ],
37
+ });
38
+ });
39
+ it("omits disabled native transitions and preserves camelCase scopes", () => {
40
+ expect(nativeTransitionProps(motionFor("none").control)).toEqual({});
41
+ expect(nativeTransitionProps(undefined)).toEqual({});
42
+ expect(nativeTransitionProps("quick")).toEqual({ transition: "quick" });
43
+ expect(nativeTransitionProps(motionFor("quick").control, ["maxHeight"])).toEqual({
44
+ transition: "quick",
45
+ animateOnly: ["backgroundColor", "borderColor", "color", "opacity", "transform", "maxHeight"],
46
+ });
47
+ });
48
+ });
@@ -1,3 +1,4 @@
1
+ import type { MotionRole } from "./motion";
1
2
  import type { TransitionProp } from "tamagui";
2
3
 
3
4
  /**
@@ -9,8 +10,31 @@ import type { TransitionProp } from "tamagui";
9
10
  * knob flips to any other stop (MPO-327). On web the key therefore stays, and
10
11
  * an undefined value paints no CSS transition. The native twin differs.
11
12
  */
12
- export function transitionProps(transition: TransitionProp | undefined): {
13
+ export function transitionProps(
14
+ role: MotionRole | TransitionProp | undefined,
15
+ extra: readonly string[] = [],
16
+ ): {
13
17
  transition?: TransitionProp;
18
+ animateOnly?: string[];
14
19
  } {
15
- return { transition };
20
+ const isRole = isMotionRole(role);
21
+ const transition = isRole ? role.transition : role;
22
+ if (!isRole) return { transition };
23
+ const properties = [...role.animateOnly, ...extra];
24
+ return {
25
+ transition,
26
+ animateOnly: properties.map((property) =>
27
+ property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`),
28
+ ),
29
+ };
30
+ }
31
+
32
+ function isMotionRole(value: MotionRole | TransitionProp | undefined): value is MotionRole {
33
+ return (
34
+ typeof value === "object" &&
35
+ value !== null &&
36
+ "transition" in value &&
37
+ "animateOnly" in value &&
38
+ Array.isArray(value.animateOnly)
39
+ );
16
40
  }
@@ -0,0 +1,68 @@
1
+ import { act, cleanup, renderHook } from "@testing-library/react";
2
+ import { createElement, type ReactNode } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { PresetContext } from "./PresetContext";
5
+ import { defaultPreset } from "./presets";
6
+ import {
7
+ getPresetChoices,
8
+ getStoredPreset,
9
+ resetPresetChoicesForTests,
10
+ setStoredPreset,
11
+ } from "./presetStore";
12
+ import { usePresetSwitch } from "./usePresetSwitch.native";
13
+
14
+ const { items } = vi.hoisted(() => ({ items: new Map<string, string>() }));
15
+ vi.mock("@multiplatform.one/store", () => ({
16
+ storage: {
17
+ getItem: (key: string) => items.get(key) ?? null,
18
+ setItem: (key: string, value: string) => {
19
+ items.set(key, value);
20
+ },
21
+ removeItem: (key: string) => {
22
+ items.delete(key);
23
+ },
24
+ },
25
+ }));
26
+ beforeEach(() => {
27
+ items.clear();
28
+ resetPresetChoicesForTests();
29
+ setStoredPreset(undefined);
30
+ });
31
+ afterEach(cleanup);
32
+
33
+ describe("native preset switching", () => {
34
+ it("switches the shared store, clears overrides and keeps callbacks stable", async () => {
35
+ const { result } = renderHook(() => usePresetSwitch());
36
+ expect(result.current.presetName).toBe("default");
37
+ const { applyPreset, choosePreset } = result.current;
38
+ act(() => setStoredPreset("dense", { elevation: "large" }));
39
+ expect(result.current.presetName).toBe("dense");
40
+ act(() => applyPreset("roomy"));
41
+ expect(result.current.presetName).toBe("roomy");
42
+ expect(getStoredPreset().overrides).toEqual({});
43
+ await vi.waitFor(() => expect(items.get("mp.preset")).toBe("roomy"));
44
+ expect(getPresetChoices()).toEqual({ users: {} });
45
+ expect(result.current.applyPreset).toBe(applyPreset);
46
+ expect(result.current.choosePreset).toBe(choosePreset);
47
+ act(() => choosePreset("paper", "a"));
48
+ expect(getPresetChoices()).toEqual({ users: { a: "paper" }, owner: "a" });
49
+ expect(result.current.presetName).toBe("paper");
50
+ act(() => choosePreset("studio"));
51
+ expect(getPresetChoices().device).toBe("studio");
52
+ });
53
+
54
+ it("prefers the provider's active name to storage", () => {
55
+ setStoredPreset("dense");
56
+ const { result } = renderHook(() => usePresetSwitch(), {
57
+ wrapper: ({ children }: { children: ReactNode }) =>
58
+ createElement(
59
+ PresetContext.Provider,
60
+ {
61
+ value: { preset: defaultPreset, presetName: "roomy" },
62
+ },
63
+ children,
64
+ ),
65
+ });
66
+ expect(result.current.presetName).toBe("roomy");
67
+ });
68
+ });
@@ -0,0 +1,32 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { usePresetContext } from "./PresetContext";
3
+ import {
4
+ getStoredPreset,
5
+ recordPresetChoice,
6
+ setStoredPreset,
7
+ subscribeStoredPreset,
8
+ } from "./presetStore";
9
+
10
+ export interface PresetSwitch {
11
+ /** The provider's active preset; "default" when none is stored. */
12
+ presetName: string;
13
+ /** Switch the active preset without recording anyone's choice. */
14
+ applyPreset(name: string): void;
15
+ /** Switch and record it as this user's choice (no user = this device's signed-out choice). */
16
+ choosePreset(name: string, userId?: string | null): void;
17
+ }
18
+
19
+ export function usePresetSwitch(): PresetSwitch {
20
+ const context = usePresetContext();
21
+ const stored = useSyncExternalStore(subscribeStoredPreset, getStoredPreset, getStoredPreset);
22
+ const presetName = context?.presetName || stored.presetName || "default";
23
+ const applyPreset = useCallback((name: string) => setStoredPreset(name), []);
24
+ const choosePreset = useCallback(
25
+ (name: string, userId?: string | null) => {
26
+ recordPresetChoice(name, userId);
27
+ applyPreset(name);
28
+ },
29
+ [applyPreset],
30
+ );
31
+ return { presetName, applyPreset, choosePreset };
32
+ }
@@ -0,0 +1,103 @@
1
+ import { act, cleanup, renderHook } from "@testing-library/react";
2
+ import { createElement, type ReactNode } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { cookiePreset, writeOverridesCookie } from "./cookies";
5
+ import { PresetContext } from "./PresetContext";
6
+ import { defaultPreset } from "./presets";
7
+ import { getPresetChoices, resetPresetChoicesForTests } from "./presetStore";
8
+ import { usePresetSwitch } from "./usePresetSwitch";
9
+
10
+ const { cookies, setCookie, removeCookie, items } = vi.hoisted(() => ({
11
+ cookies: {} as Record<string, string>,
12
+ setCookie: vi.fn(),
13
+ removeCookie: vi.fn(),
14
+ items: new Map<string, string>(),
15
+ }));
16
+ vi.mock("react-cookie", () => ({ useCookies: () => [cookies, setCookie, removeCookie] }));
17
+ vi.mock("@multiplatform.one/store", () => ({
18
+ storage: {
19
+ getItem: (key: string) => items.get(key) ?? null,
20
+ setItem: (key: string, value: string) => {
21
+ items.set(key, value);
22
+ },
23
+ removeItem: (key: string) => {
24
+ items.delete(key);
25
+ },
26
+ },
27
+ }));
28
+
29
+ beforeEach(() => {
30
+ vi.stubEnv("COOKIE_POLICY", "enabled");
31
+ Object.keys(cookies).forEach((key) => delete cookies[key]);
32
+ items.clear();
33
+ setCookie.mockReset();
34
+ removeCookie.mockReset();
35
+ resetPresetChoicesForTests();
36
+ });
37
+ afterEach(() => {
38
+ cleanup();
39
+ vi.unstubAllEnvs();
40
+ });
41
+
42
+ describe("web preset switching", () => {
43
+ it("reads the provider before the cookie and defaults without either", () => {
44
+ const plain = renderHook(() => usePresetSwitch());
45
+ expect(plain.result.current.presetName).toBe("default");
46
+ cookies[cookiePreset] = "dense";
47
+ plain.rerender();
48
+ expect(plain.result.current.presetName).toBe("dense");
49
+ const provided = renderHook(() => usePresetSwitch(), {
50
+ wrapper: ({ children }: { children: ReactNode }) =>
51
+ createElement(
52
+ PresetContext.Provider,
53
+ {
54
+ value: { preset: defaultPreset, presetName: "roomy" },
55
+ },
56
+ children,
57
+ ),
58
+ });
59
+ expect(provided.result.current.presetName).toBe("roomy");
60
+ expect(setCookie).not.toHaveBeenCalled();
61
+ expect(removeCookie).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("writes only on demand, clearing every override without recording a choice", () => {
65
+ const { result, rerender } = renderHook(() => usePresetSwitch());
66
+ const { applyPreset, choosePreset } = result.current;
67
+ cookies[cookiePreset] = "paper";
68
+ rerender();
69
+ expect(result.current.applyPreset).toBe(applyPreset);
70
+ expect(result.current.choosePreset).toBe(choosePreset);
71
+ act(() => applyPreset("dense"));
72
+ expect(setCookie).toHaveBeenCalledExactlyOnceWith(cookiePreset, "dense", {
73
+ path: "/",
74
+ sameSite: "strict",
75
+ secure: window.location.protocol === "https:",
76
+ maxAge: 60 * 60 * 24 * 365,
77
+ });
78
+ expect(removeCookie).toHaveBeenCalledWith("mp.ov.0", { path: "/" });
79
+ for (const key of writeOverridesCookie({}).toRemove) {
80
+ expect(removeCookie).toHaveBeenCalledWith(key, { path: "/" });
81
+ }
82
+ expect(getPresetChoices()).toEqual({ users: {} });
83
+ });
84
+
85
+ it("records a choice before applying it", () => {
86
+ setCookie.mockImplementation(() => {
87
+ expect(getPresetChoices().users.a).toBe("ocean");
88
+ });
89
+ const { result } = renderHook(() => usePresetSwitch());
90
+ act(() => result.current.choosePreset("ocean", "a"));
91
+ expect(setCookie).toHaveBeenCalled();
92
+ expect(getPresetChoices().owner).toBe("a");
93
+ });
94
+
95
+ it("honours disabled cookies but still remembers an explicit choice", () => {
96
+ vi.stubEnv("COOKIE_POLICY", "DiSaBlEd");
97
+ const { result } = renderHook(() => usePresetSwitch());
98
+ act(() => result.current.choosePreset("paper"));
99
+ expect(setCookie).not.toHaveBeenCalled();
100
+ expect(removeCookie).not.toHaveBeenCalled();
101
+ expect(getPresetChoices().device).toBe("paper");
102
+ });
103
+ });
@@ -0,0 +1,54 @@
1
+ import { isWeb } from "@multiplatform.one/platform";
2
+ import { useCallback } from "react";
3
+ import { useCookies } from "react-cookie";
4
+ import { cookiePreset, readPresetCookie, writeOverridesCookie } from "./cookies";
5
+ import { usePresetContext } from "./PresetContext";
6
+ import { recordPresetChoice } from "./presetStore";
7
+
8
+ export interface PresetSwitch {
9
+ /** The provider's active preset; "default" when none is stored. */
10
+ presetName: string;
11
+ /** Switch the active preset without recording anyone's choice. */
12
+ applyPreset(name: string): void;
13
+ /** Switch and record it as this user's choice (no user = this device's signed-out choice). */
14
+ choosePreset(name: string, userId?: string | null): void;
15
+ }
16
+
17
+ export function usePresetSwitch(): PresetSwitch {
18
+ const isCookieDisabled =
19
+ typeof process !== "undefined" && process.env?.COOKIE_POLICY?.toLowerCase() === "disabled";
20
+ const [cookies, setCookie, removeCookie] = useCookies<string, Record<string, string>>(
21
+ isCookieDisabled ? [] : [cookiePreset],
22
+ );
23
+ const context = usePresetContext();
24
+ const presetName = context?.presetName || readPresetCookie(cookies) || "default";
25
+
26
+ const applyPreset = useCallback(
27
+ (name: string) => {
28
+ if (isCookieDisabled || typeof window === "undefined") return;
29
+ setCookie(cookiePreset, name, {
30
+ path: "/",
31
+ sameSite: "strict",
32
+ secure: isWeb && window.location.protocol === "https:",
33
+ maxAge: 60 * 60 * 24 * 365,
34
+ });
35
+ const { toSet, toRemove } = writeOverridesCookie({});
36
+ for (const key of [...toSet.map(({ key }) => key), ...toRemove]) {
37
+ try {
38
+ removeCookie(key, { path: "/" });
39
+ } catch {}
40
+ }
41
+ },
42
+ [isCookieDisabled, setCookie, removeCookie],
43
+ );
44
+
45
+ const choosePreset = useCallback(
46
+ (name: string, userId?: string | null) => {
47
+ recordPresetChoice(name, userId);
48
+ applyPreset(name);
49
+ },
50
+ [applyPreset],
51
+ );
52
+
53
+ return { presetName, applyPreset, choosePreset };
54
+ }
@@ -103,7 +103,7 @@ describe("responsive knob hydration", () => {
103
103
  await hydrate();
104
104
  expect(observed[0].height).toBe(serverHeight);
105
105
  expect(container.querySelector("button")).toBe(serverControl);
106
- expect(container.querySelector("button")?.getAttribute("data-height")).toBe("48");
106
+ expect(container.querySelector("button")?.getAttribute("data-height")).toBe("44");
107
107
  expect(errors).toEqual([]);
108
108
  });
109
109
 
@@ -135,7 +135,7 @@ describe("responsive knob hydration", () => {
135
135
  host.web = false;
136
136
  host.touch = true;
137
137
  container.innerHTML = renderToString(<Fixture />);
138
- expect(observed[0].height).toBe(48);
138
+ expect(observed[0].height).toBe(44);
139
139
  expect(errors).toEqual([]);
140
140
  });
141
141
  });