@multiplatform.one/theme 7.34.4 → 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
@@ -0,0 +1,95 @@
1
+ import { useEffect, useLayoutEffect, useState } from "react";
2
+ import type { ThemeConfig } from "../shared";
3
+ import { setSizeRecipeInputs } from "../sizeRecipes";
4
+ import { setActiveScaleTokens } from "./activeScale";
5
+ import { installScaleConfig } from "./installScaleConfig";
6
+ import { DEFAULT_SCALE_ID, type ThemeScale } from "./themeScales";
7
+
8
+ /** What a provider needs to pick a scale: the config's scales and its default preset name. */
9
+ export type ScaleSource = Partial<Pick<ThemeConfig, "scales" | "defaultPreset">>;
10
+
11
+ /** The px maps `globalThis.__MPO_SCALE__` publishes for in-page audits. */
12
+ export type PublishedScale = Pick<ThemeScale, "id" | "inputs" | "tokens" | "fonts"> & {
13
+ size: ThemeScale["tokens"]["size"];
14
+ space: ThemeScale["tokens"]["space"];
15
+ radius: ThemeScale["tokens"]["radius"];
16
+ type: number;
17
+ controlGaps: number[];
18
+ };
19
+
20
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
21
+
22
+ let appliedScale: ThemeScale | undefined;
23
+
24
+ /** The scale a preset name resolves to; unknown and empty names take the default preset's. */
25
+ export function resolveThemeScale(
26
+ config: ScaleSource | undefined,
27
+ presetName?: string,
28
+ ): ThemeScale | undefined {
29
+ const scales = config?.scales;
30
+ if (!scales) return undefined;
31
+ const name = presetName || config.defaultPreset || DEFAULT_SCALE_ID;
32
+ return scales[name] ?? scales[DEFAULT_SCALE_ID];
33
+ }
34
+
35
+ function publishScale(scale: ThemeScale): void {
36
+ const published: PublishedScale = {
37
+ id: scale.id,
38
+ inputs: scale.inputs,
39
+ tokens: scale.tokens,
40
+ fonts: scale.fonts,
41
+ size: scale.tokens.size,
42
+ space: scale.tokens.space,
43
+ radius: scale.tokens.radius,
44
+ type: scale.inputs.type,
45
+ controlGaps: [
46
+ ...new Set(
47
+ [
48
+ ...Object.values(scale.recipeInputs.tokenTable),
49
+ ...Object.values(scale.recipeInputs.chipTokenTable),
50
+ ].map((row) => row.radius),
51
+ ),
52
+ ].sort((a, b) => a - b),
53
+ };
54
+ (globalThis as { __MPO_SCALE__?: PublishedScale }).__MPO_SCALE__ = published;
55
+ }
56
+
57
+ /**
58
+ * Install `scale` for every JS reader: its Tamagui config becomes the global
59
+ * one (`getTokenValue`, native `.val` resolution) and its recipe inputs
60
+ * rewrite the live size-recipe tables (`knobProps.control`, Button/Input).
61
+ * Idempotent; returns true when anything changed.
62
+ */
63
+ export function applyThemeScale(scale: ThemeScale): boolean {
64
+ const configChanged = installScaleConfig(scale.tamagui());
65
+ const scaleChanged = appliedScale !== scale;
66
+ if (configChanged || scaleChanged) setSizeRecipeInputs(scale.recipeInputs);
67
+ setActiveScaleTokens(scale.tokens);
68
+ appliedScale = scale;
69
+ publishScale(scale);
70
+ return configChanged || scaleChanged;
71
+ }
72
+
73
+ /**
74
+ * Keep the active preset's scale installed and return its id.
75
+ *
76
+ * The first render installs it synchronously, so SSR markup and the
77
+ * hydrating render read the same recipe tables. Later switches install in a
78
+ * layout effect and then return the new id, so a context keyed on it
79
+ * re-renders every reader after the tables changed and before paint.
80
+ */
81
+ export function useActiveScale(config: ScaleSource | undefined, presetName?: string): string {
82
+ const scale = resolveThemeScale(config, presetName);
83
+ const [active, setActive] = useState(() => {
84
+ if (scale) applyThemeScale(scale);
85
+ return scale?.id ?? DEFAULT_SCALE_ID;
86
+ });
87
+
88
+ useIsomorphicLayoutEffect(() => {
89
+ if (!scale) return;
90
+ applyThemeScale(scale);
91
+ setActive(scale.id);
92
+ }, [scale]);
93
+
94
+ return active;
95
+ }
@@ -0,0 +1,81 @@
1
+ import { beforeEach, expect, it, vi } from "vitest";
2
+
3
+ const backend = vi.hoisted(() => ({
4
+ data: new Map<string, string>(),
5
+ getItem: vi.fn(),
6
+ setItem: vi.fn(),
7
+ }));
8
+ vi.mock("@multiplatform.one/store", () => ({ storage: backend }));
9
+ beforeEach(() => {
10
+ vi.resetModules();
11
+ backend.data.clear();
12
+ backend.getItem.mockReset().mockImplementation((key) => backend.data.get(key) ?? null);
13
+ backend.setItem.mockReset().mockImplementation((key, value) => {
14
+ backend.data.set(key, value);
15
+ });
16
+ });
17
+
18
+ it("persists the scheme under the existing preload key across a fresh module", async () => {
19
+ let store = await import("./schemeStore");
20
+ store.setStoredScheme("dark");
21
+ await vi.waitFor(() => expect(backend.data.get("vxrn-scheme")).toBe("dark"));
22
+ vi.resetModules();
23
+ store = await import("./schemeStore");
24
+ await store.hydrateStoredScheme();
25
+ expect(store.getStoredScheme()).toBe("dark");
26
+ });
27
+
28
+ it("ignores invalid storage and protects early choices from late hydration", async () => {
29
+ backend.data.set("vxrn-scheme", "invalid");
30
+ let store = await import("./schemeStore");
31
+ await store.hydrateStoredScheme();
32
+ expect(store.getStoredScheme()).toBe("system");
33
+ vi.resetModules();
34
+ let resolve!: (value: string) => void;
35
+ backend.getItem.mockImplementation(
36
+ () =>
37
+ new Promise((r) => {
38
+ resolve = r;
39
+ }),
40
+ );
41
+ store = await import("./schemeStore");
42
+ const hydration = store.hydrateStoredScheme();
43
+ store.setStoredScheme("light");
44
+ resolve("dark");
45
+ await hydration;
46
+ expect(store.getStoredScheme()).toBe("light");
47
+ });
48
+
49
+ it("serializes scheme writes and recovers after a rejected write", async () => {
50
+ let finish!: () => void;
51
+ backend.setItem.mockImplementationOnce(
52
+ () =>
53
+ new Promise<void>((resolve) => {
54
+ finish = resolve;
55
+ }),
56
+ );
57
+ const store = await import("./schemeStore");
58
+ store.setStoredScheme("dark");
59
+ store.setStoredScheme("light");
60
+ await vi.waitFor(() => expect(backend.setItem).toHaveBeenCalledTimes(1));
61
+ finish();
62
+ await vi.waitFor(() => expect(backend.data.get("vxrn-scheme")).toBe("light"));
63
+ backend.setItem.mockRejectedValueOnce(new Error("offline"));
64
+ store.setStoredScheme("dark");
65
+ store.setStoredScheme("system");
66
+ await vi.waitFor(() => expect(backend.data.get("vxrn-scheme")).toBe("system"));
67
+ });
68
+
69
+ it("does not hydrate or mutate a process-wide preference on web SSR", async () => {
70
+ const store = await import("./schemeStore");
71
+ vi.stubGlobal("window", undefined);
72
+ try {
73
+ store.setStoredScheme("dark");
74
+ await store.hydrateStoredScheme();
75
+ expect(store.getStoredScheme()).toBe("system");
76
+ expect(backend.getItem).not.toHaveBeenCalled();
77
+ expect(backend.setItem).not.toHaveBeenCalled();
78
+ } finally {
79
+ vi.unstubAllGlobals();
80
+ }
81
+ });
@@ -0,0 +1,67 @@
1
+ import { isWeb } from "@multiplatform.one/platform";
2
+ import { storage } from "@multiplatform.one/store";
3
+ import type { ThemeScheme } from "./useTheme";
4
+
5
+ const schemeKey = "vxrn-scheme";
6
+ const listeners = new Set<() => void>();
7
+ let setting: ThemeScheme = "system";
8
+ let hydration: Promise<void> | undefined;
9
+ let written = false;
10
+ let writes = Promise.resolve();
11
+ const isServer = () => isWeb && typeof window === "undefined";
12
+
13
+ function emit(value: ThemeScheme) {
14
+ if (value === setting) return;
15
+ setting = value;
16
+ listeners.forEach((listener) => listener());
17
+ }
18
+ function adopt(value: unknown) {
19
+ if (!written) emit(value === "light" || value === "dark" ? value : "system");
20
+ }
21
+
22
+ export function hydrateStoredScheme(): Promise<void> {
23
+ if (isServer()) return Promise.resolve();
24
+ if (hydration) return hydration;
25
+ let raw: ReturnType<typeof storage.getItem>;
26
+ try {
27
+ raw = storage.getItem(schemeKey);
28
+ } catch {
29
+ raw = null;
30
+ }
31
+ if (!raw || typeof raw === "string") {
32
+ adopt(raw);
33
+ hydration = Promise.resolve();
34
+ } else {
35
+ let timer: ReturnType<typeof setTimeout>;
36
+ const timeout = new Promise<null>((resolve) => {
37
+ timer = setTimeout(() => resolve(null), 1000);
38
+ });
39
+ hydration = Promise.race([raw, timeout])
40
+ .then(adopt)
41
+ .catch(() => adopt(null))
42
+ .finally(() => clearTimeout(timer));
43
+ }
44
+ return hydration;
45
+ }
46
+
47
+ export function getStoredScheme(): ThemeScheme {
48
+ if (isServer()) return "system";
49
+ if (!hydration) void hydrateStoredScheme();
50
+ return setting;
51
+ }
52
+
53
+ export function subscribeStoredScheme(listener: () => void): () => void {
54
+ if (isServer()) return () => {};
55
+ if (!hydration) void hydrateStoredScheme();
56
+ listeners.add(listener);
57
+ return () => {
58
+ listeners.delete(listener);
59
+ };
60
+ }
61
+
62
+ export function setStoredScheme(value: ThemeScheme): void {
63
+ if (isServer()) return;
64
+ written = true;
65
+ emit(value);
66
+ writes = writes.then(() => storage.setItem(schemeKey, value)).catch(() => undefined);
67
+ }
@@ -65,20 +65,21 @@ describe("resolveThemeFromPreset", () => {
65
65
 
66
66
  it("merges overrides on top of preset knobs", () => {
67
67
  registerPreset("myPreset", testPreset);
68
- const resolved = resolveThemeFromPreset("myPreset", { size: "large" });
69
- expect(resolved.knobs.size).toBe("large");
68
+ const resolved = resolveThemeFromPreset("myPreset", { elevation: "large" });
69
+ expect(resolved.knobs.elevation).toBe("large");
70
70
  expect(resolved.knobs.fillStyle).toBe("outlined");
71
71
  });
72
72
 
73
73
  it("falls back to defaultKnobs for unknown preset", () => {
74
- const resolved = resolveThemeFromPreset("unknown", { size: "small" });
75
- expect(resolved.knobs.size).toBe("small");
74
+ const resolved = resolveThemeFromPreset("unknown", { elevation: "none" });
75
+ expect(resolved.knobs.elevation).toBe("none");
76
76
  expect(resolved.knobs.fillStyle).toBe(defaultKnobs.fillStyle);
77
77
  });
78
78
 
79
79
  it("does not mutate the original preset", () => {
80
80
  registerPreset("immutable", testPreset);
81
- resolveThemeFromPreset("immutable", { size: "large" });
82
- expect(testPreset.knobs.size).toBe("medium");
81
+ const before = testPreset.knobs.elevation;
82
+ resolveThemeFromPreset("immutable", { elevation: before === "large" ? "none" : "large" });
83
+ expect(testPreset.knobs.elevation).toBe(before);
83
84
  });
84
85
  });
@@ -1,9 +1,11 @@
1
- import type { ThemeName } from "@tamagui/web";
1
+ import type { GenericFont, TamaguiInternalConfig, ThemeName } from "@tamagui/web";
2
2
  import { type CreateTamaguiProps, createTamagui } from "tamagui";
3
3
  import type { Knobs } from "./knobs";
4
4
  import { defaultKnobs } from "./knobs";
5
5
  import type { Preset } from "./preset.types";
6
6
  import type { RecipeFamilies, RecipeInputs } from "./recipeInputs";
7
+ import type { ScaleFonts } from "./scale/generateScale";
8
+ import { type ThemeScale, buildThemeScales } from "./scale/themeScales";
7
9
 
8
10
  // ── Preset registry ───────────────────────────────────────────────────────
9
11
 
@@ -90,8 +92,23 @@ export type ThemeConfig<T extends CreateTamaguiProps = CreateTamaguiProps> = {
90
92
  */
91
93
  recipeInputs?: RecipeInputs;
92
94
  recipeFamilies?: RecipeFamilies;
95
+ /**
96
+ * The token scales this config can switch to at runtime, keyed by preset
97
+ * name (`"default"` = the implicit default preset). Presets on equal scale
98
+ * inputs share one entry. Present when the config was built from a
99
+ * generated scale (`createDefaultThemeConfig`).
100
+ */
101
+ scales?: Record<string, ThemeScale>;
93
102
  };
94
103
 
104
+ /** How `createThemeConfig` builds the preset scales of a generated-scale config. */
105
+ export interface ThemeConfigScaleOptions {
106
+ /** The font map a scale's type tables produce. */
107
+ fonts: (tables: ScaleFonts) => Record<string, GenericFont>;
108
+ /** Consumer recipe overrides that are not scale-derived (tile ladder, touch floor). */
109
+ recipeOverrides?: Partial<RecipeInputs>;
110
+ }
111
+
95
112
  /**
96
113
  * Semantic / internal sub-theme names that should never appear in a
97
114
  * user-facing "Theme color" picker.
@@ -133,6 +150,7 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
133
150
  defaultPreset?: string;
134
151
  recipeInputs?: RecipeInputs;
135
152
  recipeFamilies?: RecipeFamilies;
153
+ scale?: ThemeConfigScaleOptions;
136
154
  },
137
155
  ): ThemeConfig<T> {
138
156
  // Extract color names from the raw themes BEFORE createTamagui processes them.
@@ -140,14 +158,15 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
140
158
  ? extractThemeColors(tamagui.themes as Record<string, unknown>)
141
159
  : [];
142
160
 
143
- const tamaguiConfig = createTamagui({
161
+ const props = {
144
162
  ...tamagui,
145
163
  settings: {
146
164
  disableRootThemeClass: false,
147
165
  themeClassNameOnRoot: true,
148
166
  ...tamagui.settings,
149
167
  },
150
- }) as ReturnType<typeof createTamagui<T>>;
168
+ };
169
+ const tamaguiConfig = createTamagui(props) as ReturnType<typeof createTamagui<T>>;
151
170
 
152
171
  if (options?.presets) {
153
172
  for (const [name, preset] of Object.entries(options.presets)) {
@@ -155,6 +174,20 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
155
174
  }
156
175
  }
157
176
 
177
+ const scales =
178
+ options?.scale && options.recipeInputs
179
+ ? buildThemeScales({
180
+ base: tamaguiConfig as unknown as TamaguiInternalConfig,
181
+ props,
182
+ presets: options.presets,
183
+ defaultPreset: options.defaultPreset,
184
+ recipeInputs: options.recipeInputs,
185
+ recipeOverrides: options.scale.recipeOverrides,
186
+ fonts: options.scale.fonts,
187
+ createConfig: (next) => createTamagui(next) as unknown as TamaguiInternalConfig,
188
+ })
189
+ : undefined;
190
+
158
191
  return {
159
192
  tamagui: tamaguiConfig,
160
193
  presets: options?.presets,
@@ -162,5 +195,6 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
162
195
  themeColors,
163
196
  recipeInputs: options?.recipeInputs,
164
197
  recipeFamilies: options?.recipeFamilies,
198
+ scales,
165
199
  };
166
200
  }
@@ -1,17 +1,10 @@
1
- /**
2
- * MPO-46 / MPO-19 X1/X2 — the size ladder is the measured Tamagui v5 table
3
- * (R6: real TABLE, no escape). padX / label / icon / radius at $2/$3/$4/$6
4
- * come from tamagui-rendered-reference.md §6.1; Input padX is §6.2
5
- * (one space half-step below the button). Default gap equals size-table
6
- * radius; borderRadius shape overrides preserve that internal spacing.
7
- */
8
-
1
+ import { generateScale } from "./scale/generateScale";
2
+ import { defaultScaleInputs } from "./scale/scaleInputs";
9
3
  import { describe, expect, it } from "vitest";
10
4
  import {
5
+ DEFAULT_SIZE_RECIPE_INPUTS,
11
6
  SIZE_RECIPE_RADII,
12
7
  SIZE_RECIPE_RADIUS_STOPS,
13
- TAMAGUI_CONTROL_SIZE_TABLE,
14
- TAMAGUI_INPUT_PADX,
15
8
  applyRadiusStop,
16
9
  generateSizeRecipes,
17
10
  recipeFamilies,
@@ -21,23 +14,19 @@ import {
21
14
  type SizeRecipeRadiusStop,
22
15
  } from "./sizeRecipes";
23
16
 
24
- const MEASURED = {
25
- $2: { paddingHorizontal: 7, fontSize: 12, iconSize: 12, radius: 5 },
26
- $3: { paddingHorizontal: 13, fontSize: 13, iconSize: 14, radius: 7 },
27
- $4: { paddingHorizontal: 18, fontSize: 14, iconSize: 16, radius: 9 },
28
- $6: { paddingHorizontal: 32, fontSize: 18, iconSize: 20, radius: 16 },
29
- } as const;
17
+ const scale = generateScale(defaultScaleInputs);
18
+ const MEASURED = scale.recipeInputs.tokenTable;
30
19
 
31
- describe("MPO-46 size ladder — measured table, not a ratio (R6)", () => {
32
- it("padX, label, icon and radius at $2/$3/$4/$6 match the Tamagui v5 reference", () => {
20
+ describe("MPO-46 size ladder — generated table, not a ratio (R6)", () => {
21
+ it("padX, label, icon and radius at $2/$3/$4/$6 match the generated scale", () => {
33
22
  for (const token of ["$2", "$3", "$4", "$6"] as const) {
34
23
  const recipe = sizeRecipeForToken(token);
35
- const row = MEASURED[token];
24
+ const row = MEASURED[token]!;
36
25
  expect(recipe.paddingHorizontal, `${token} padX`).toBe(row.paddingHorizontal);
37
26
  expect(recipe.fontSize, `${token} label`).toBe(row.fontSize);
38
27
  expect(recipe.iconSize, `${token} icon`).toBe(row.iconSize);
39
28
  expect(SIZE_RECIPE_RADII[token], `${token} radius`).toBe(row.radius);
40
- expect(TAMAGUI_CONTROL_SIZE_TABLE[token]).toEqual(row);
29
+ expect(DEFAULT_SIZE_RECIPE_INPUTS.tokenTable[token]).toEqual(row);
41
30
  }
42
31
  });
43
32
 
@@ -49,15 +38,15 @@ describe("MPO-46 size ladder — measured table, not a ratio (R6)", () => {
49
38
  expect(SIZE_RECIPE_RADII.$2).not.toBe(SIZE_RECIPE_RADII.$6);
50
39
  });
51
40
 
52
- it("Button / Select trigger / ListItem $4 is 44 / padX 18 / radius 9; Input padX is 16", () => {
41
+ it("Button / Select trigger / ListItem $4 is 40 / padX 16 / radius 8; Input padX is 14", () => {
53
42
  const button = sizeRecipes.$4;
54
- expect(button.height).toBe(44);
55
- expect(button.paddingHorizontal).toBe(18);
56
- expect(SIZE_RECIPE_RADII.$4).toBe(9);
57
- expect(TAMAGUI_INPUT_PADX.$4).toBe(16);
58
- expect(recipeFamilies.controlCompact.recipes.$4.paddingHorizontal).toBe(16);
59
- expect(recipeFamilies.controlCompact.recipes.$4.height).toBe(44);
60
- expect(recipeFamilies.controlCompact.radii.$4).toBe(9);
43
+ expect(button.height).toBe(40);
44
+ expect(button.paddingHorizontal).toBe(16);
45
+ expect(SIZE_RECIPE_RADII.$4).toBe(8);
46
+ expect(DEFAULT_SIZE_RECIPE_INPUTS.inputPadX.$4).toBe(14);
47
+ expect(recipeFamilies.chip.recipes.$4.paddingHorizontal).toBe(14);
48
+ expect(recipeFamilies.chip.recipes.$4.height).toBe(40);
49
+ expect(recipeFamilies.chip.radii.$4).toBe(8);
61
50
  });
62
51
  });
63
52
 
@@ -66,8 +55,8 @@ describe("MPO-46 default size-table gap and explicit recipe transformation", ()
66
55
  const generated = generateSizeRecipes();
67
56
  for (const token of Object.keys(generated.recipes)) {
68
57
  expect(generated.recipes[token].gap, `control ${token}`).toBe(generated.radii[token]);
69
- expect(generated.families.controlCompact.recipes[token].gap, `compact ${token}`).toBe(
70
- generated.families.controlCompact.radii[token],
58
+ expect(generated.families.chip.recipes[token].gap, `compact ${token}`).toBe(
59
+ generated.families.chip.radii[token],
71
60
  );
72
61
  }
73
62
  });
@@ -85,17 +74,17 @@ describe("MPO-46 default size-table gap and explicit recipe transformation", ()
85
74
  });
86
75
 
87
76
  describe("MPO-46 resolveControlRadius", () => {
88
- it("default medium follows the size table, not the 9px knob", () => {
77
+ it("default medium follows the size table, not the base radius knob", () => {
89
78
  const two = resolveControlRadius(sizeRecipeForToken("$2"), "medium");
90
- expect(two.radius).toBe(5);
91
- expect(two.gap).toBe(5);
79
+ expect(two.radius).toBe(scale.tokens.radius[2]);
80
+ expect(two.gap).toBe(scale.tokens.radius[2]);
92
81
  const six = resolveControlRadius(sizeRecipeForToken("$6"), "medium");
93
- expect(six.radius).toBe(16);
94
- expect(six.gap).toBe(16);
82
+ expect(six.radius).toBe(scale.tokens.radius[6]);
83
+ expect(six.gap).toBe(scale.tokens.radius[6]);
95
84
  });
96
85
 
97
86
  it("corner shape preserves the size recipe's icon-label grouping", () => {
98
- for (const family of ["control", "controlCompact"] as const) {
87
+ for (const family of ["control", "chip"] as const) {
99
88
  for (const token of ["$2", "$3", "$4", "$6"]) {
100
89
  const recipe = sizeRecipeForToken(token, { family });
101
90
  for (const stop of Object.keys(SIZE_RECIPE_RADIUS_STOPS) as SizeRecipeRadiusStop[]) {
@@ -111,3 +100,15 @@ describe("MPO-46 resolveControlRadius", () => {
111
100
  }
112
101
  });
113
102
  });
103
+
104
+ describe("one touch ladder", () => {
105
+ it("floors the generated height and preserves every other channel", () => {
106
+ for (const token of Object.keys(sizeRecipes)) {
107
+ const recipe = sizeRecipes[token];
108
+ expect(sizeRecipeForToken(token, { touch: true })).toEqual({
109
+ ...recipe,
110
+ height: Math.max(recipe.height, 44),
111
+ });
112
+ }
113
+ });
114
+ });