@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
@@ -1,9 +1,17 @@
1
1
  import * as Colors from "@tamagui/colors";
2
- import { themes as stockThemes } from "@tamagui/themes";
3
2
  import { describe, expect, it } from "vitest";
4
3
  import { accentIdentityCandidates, resolveChartPalette } from "./chartPalette";
5
4
  import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
6
- import { tintHueNames } from "./createThemes";
5
+ import { createThemesBuilder, tintHueNames } from "./createThemes";
6
+ import { defaultAccentTheme } from "./defaults/accent";
7
+ import { defaultBaseTheme } from "./defaults/base";
8
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
9
+
10
+ const builtThemes = createThemesBuilder(
11
+ defaultBaseTheme,
12
+ defaultAccentTheme,
13
+ defaultBuilderOptions,
14
+ ).themes() as unknown as Record<string, Record<string, string>>;
7
15
 
8
16
  // The default accent solid (accentBackground) from defaults/accent.ts.
9
17
  const lightAccent = "hsla(250, 50%, 54%, 1)";
@@ -57,30 +65,26 @@ function adjacentDistinguishable(a: string, b: string): boolean {
57
65
  describe("resolveChartPalette", () => {
58
66
  it("floors every categorical mark across the registered tint identities", () => {
59
67
  for (const scheme of ["light", "dark"] as const) {
60
- const cardSurfaces = Object.entries(stockThemes)
61
- .filter(
62
- ([name]) =>
63
- name.startsWith(`${scheme}_`) &&
64
- name.endsWith("_Card") &&
65
- tintHueNames.has(name.split("_")[1]) &&
66
- name.split("_").length === 3,
67
- )
68
- .map(([, theme]) => theme.background);
69
- expect(cardSurfaces.length).toBeGreaterThan(0);
70
- const surfaces = [
71
- ...cardSurfaces,
72
- ...(scheme === "light"
68
+ const builtSurfaces = [scheme, ...[...tintHueNames].map((hue) => `${scheme}_${hue}`)]
69
+ .filter((name) => builtThemes[name])
70
+ .flatMap((name) => [builtThemes[name].background, builtThemes[`${name}_Card`].background]);
71
+ expect(builtSurfaces.length).toBeGreaterThan(2);
72
+ const references =
73
+ scheme === "light"
73
74
  ? ["#ffffff", Colors.gray.gray2, Colors.mauve.mauve2]
74
- : [Colors.grayDark.gray2, Colors.mauveDark.mauve2]),
75
- ];
75
+ : [Colors.grayDark.gray2, Colors.mauveDark.mauve2];
76
+ const surfaces = [...builtSurfaces, ...references];
76
77
  for (const hue of tintHueNames) {
77
78
  const ramp = (Colors as unknown as Record<string, Record<string, string>>)[
78
79
  scheme === "dark" ? `${hue}Dark` : hue
79
80
  ];
81
+ const tint = builtThemes[`${scheme}_${hue}`];
82
+ const tintSurfaces = tint ? [tint.color2, tint.color3] : [];
80
83
  const palette = resolveChartPalette({
81
84
  scheme,
82
85
  identitySolid: ramp[`${hue}9`],
83
86
  identityCandidates: [ramp[`${hue}10`], ramp[`${hue}11`]],
87
+ surfaces: tintSurfaces,
84
88
  });
85
89
  for (let i = 0; i < palette.categorical.length; i++) {
86
90
  expect(
@@ -91,14 +95,19 @@ describe("resolveChartPalette", () => {
91
95
  `${scheme}/${hue}: adjacent series ${i}`,
92
96
  ).toBe(true);
93
97
  }
94
- for (const paint of [...palette.categorical, ...Object.values(palette.semantic)]) {
95
- for (const surface of surfaces) {
96
- const ratio = contrastRatio(
97
- relativeLuminance(normalizeToHex(paint)!),
98
- relativeLuminance(normalizeToHex(surface)!),
99
- );
100
- expect(ratio, `${scheme}/${hue}: ${paint} on ${surface}`).toBeGreaterThanOrEqual(3);
101
- }
98
+ const [identity, ...cycle] = palette.categorical;
99
+ const marks = [
100
+ ...[...references, ...tintSurfaces].map((surface) => [identity, surface]),
101
+ ...[...cycle, ...Object.values(palette.semantic)].flatMap((paint) =>
102
+ surfaces.map((surface) => [paint, surface]),
103
+ ),
104
+ ];
105
+ for (const [paint, surface] of marks) {
106
+ const ratio = contrastRatio(
107
+ relativeLuminance(normalizeToHex(paint)!),
108
+ relativeLuminance(normalizeToHex(surface)!),
109
+ );
110
+ expect(ratio, `${scheme}/${hue}: ${paint} on ${surface}`).toBeGreaterThanOrEqual(3);
102
111
  }
103
112
  }
104
113
  }
@@ -128,7 +137,7 @@ describe("resolveChartPalette", () => {
128
137
  Colors.green.green10,
129
138
  Colors.amber.amber11,
130
139
  Colors.pink.pink9,
131
- Colors.teal.teal10,
140
+ Colors.teal.teal11,
132
141
  Colors.red.red9,
133
142
  ]);
134
143
  const grass = resolveChartPalette({
@@ -144,7 +153,7 @@ describe("resolveChartPalette", () => {
144
153
  Colors.orange.orange11,
145
154
  Colors.amber.amber11,
146
155
  Colors.pink.pink9,
147
- Colors.teal.teal10,
156
+ Colors.teal.teal11,
148
157
  Colors.violet.violet9,
149
158
  Colors.red.red9,
150
159
  ].sort(),
@@ -175,6 +184,25 @@ describe("resolveChartPalette", () => {
175
184
  ).toBe(Colors.violet.violet9);
176
185
  });
177
186
 
187
+ it("steps the identity down when it misses the tint's Card fill", () => {
188
+ const tint = builtThemes.light_gray;
189
+ const card = builtThemes.light_gray_Card.background;
190
+ expect(card).toBe(tint.color3);
191
+ const onCard = resolveChartPalette({
192
+ scheme: "light",
193
+ identitySolid: tint.color9,
194
+ identityCandidates: [tint.color10, tint.color11],
195
+ surfaces: [tint.color2, tint.color3],
196
+ });
197
+ expect(onCard.single).toBe(tint.color10);
198
+ expect(
199
+ contrastRatio(
200
+ relativeLuminance(normalizeToHex(onCard.single)!),
201
+ relativeLuminance(normalizeToHex(card)!),
202
+ ),
203
+ ).toBeGreaterThanOrEqual(3);
204
+ });
205
+
178
206
  it("single-series takes the theme identity solid", () => {
179
207
  expect(resolveChartPalette({ scheme: "light", identitySolid: lightAccent }).single).toBe(
180
208
  lightAccent,
@@ -295,6 +323,7 @@ describe("chart identity under the held dark accent (MPO-448)", () => {
295
323
  ...accentIdentityCandidates(identitySolid, darkAccentSolids),
296
324
  "hsla(270, 6%, 54%, 1)",
297
325
  ],
326
+ surfaces: [builtThemes.dark_purple.color2, builtThemes.dark_purple.color3],
298
327
  });
299
328
  expect(palette.single).toBe(darkAccentSolids[9]);
300
329
  });
@@ -1,5 +1,4 @@
1
1
  import * as Colors from "@tamagui/colors";
2
- import { themes as stockThemes } from "@tamagui/themes";
3
2
  import { useMemo } from "react";
4
3
  import { useTheme as useTamaguiTheme, useThemeName } from "tamagui";
5
4
  import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
@@ -41,20 +40,29 @@ export interface ChartPalette {
41
40
  export interface ResolveChartPaletteInput {
42
41
  scheme: "light" | "dark";
43
42
  /**
44
- * Resolved theme-identity solid — `accentBackground` in base themes, the
43
+ * Resolved theme-identity solid — the accent solid in base themes, the
45
44
  * re-ramped `$color9` under a tint. Tries the ordered candidates, then
46
45
  * falls back to violet when missing, unparseable or below the contrast floor.
47
46
  */
48
47
  identitySolid?: string;
49
48
  /** Ordered alternatives when the identity cannot meet the mark contrast floor. */
50
49
  identityCandidates?: readonly (string | undefined)[];
50
+ /**
51
+ * The active colour theme's page and Card fills (`$color2` and `$color3`
52
+ * under v5's templates, the same in every sub-theme of that colour). A
53
+ * tint's surfaces are deeper than the reference whites, so the identity
54
+ * must clear them too, and one palette serves a chart on the page or in a
55
+ * Card.
56
+ */
57
+ surfaces?: readonly (string | undefined)[];
51
58
  }
52
59
 
53
60
  /**
54
61
  * Fixed categorical hue cycle: Radix solids ordered so neighbouring
55
62
  * entries sit far apart on the hue wheel (min adjacent distance ~78°).
56
63
  * Light blue, orange, green, amber and teal deepen within their hue to clear
57
- * 3:1 on reference cards. Dark violet deepens for tinted card backgrounds.
64
+ * 3:1 on every built page and Card surface. Dark violet deepens for tinted
65
+ * card backgrounds.
58
66
  */
59
67
  const categoricalHueCycle = [
60
68
  { family: "blue", light: Colors.blue.blue10, dark: Colors.blueDark.blue9 },
@@ -62,37 +70,31 @@ const categoricalHueCycle = [
62
70
  { family: "green", light: Colors.green.green10, dark: Colors.greenDark.green9 },
63
71
  { family: "amber", light: Colors.amber.amber11, dark: Colors.amberDark.amber9 },
64
72
  { family: "pink", light: Colors.pink.pink9, dark: Colors.pinkDark.pink9 },
65
- { family: "teal", light: Colors.teal.teal10, dark: Colors.tealDark.teal9 },
73
+ { family: "teal", light: Colors.teal.teal11, dark: Colors.tealDark.teal9 },
66
74
  { family: "violet", light: Colors.violet.violet9, dark: Colors.violetDark.violet10 },
67
75
  { family: "red", light: Colors.red.red9, dark: Colors.redDark.red9 },
68
76
  ] as const;
69
77
 
70
- function configuredTintCardSurfaces(scheme: "light" | "dark"): string[] {
71
- const themes = stockThemes as unknown as Record<string, { background?: string }>;
72
- return [...tintHueNames].flatMap((hue) => {
73
- const color = themes[`${scheme}_${hue}_Card`]?.background;
74
- return color ? [color] : [];
75
- });
76
- }
77
-
78
- // Use the shipped tinted Card backgrounds, which are deeper than their page
79
- // colors. Missing theme names are not invented surfaces. The rendered audit
80
- // still owns actual custom consumer backgrounds and elevation variants.
78
+ // The rendered audit still owns custom consumer backgrounds and elevation
79
+ // variants.
81
80
  const chartReferenceSurfaces = {
82
- light: [
83
- "#ffffff",
84
- Colors.gray.gray2,
85
- Colors.mauve.mauve2,
86
- ...configuredTintCardSurfaces("light"),
87
- ],
88
- dark: [Colors.grayDark.gray2, Colors.mauveDark.mauve2, ...configuredTintCardSurfaces("dark")],
81
+ light: ["#ffffff", Colors.gray.gray2, Colors.mauve.mauve2],
82
+ dark: [Colors.grayDark.gray2, Colors.mauveDark.mauve2],
89
83
  } as const;
90
84
 
91
- function readableIdentity(color: string | undefined, scheme: "light" | "dark"): color is string {
85
+ function readableIdentity(
86
+ color: string | undefined,
87
+ scheme: "light" | "dark",
88
+ themeSurfaces: readonly (string | undefined)[],
89
+ ): color is string {
92
90
  const hex = color ? normalizeToHex(color) : null;
93
91
  if (!hex) return false;
94
92
  const luminance = relativeLuminance(hex);
95
- return chartReferenceSurfaces[scheme].every((surface) => {
93
+ const surfaces = [
94
+ ...chartReferenceSurfaces[scheme],
95
+ ...themeSurfaces.filter((surface): surface is string => !!surface),
96
+ ];
97
+ return surfaces.every((surface) => {
96
98
  const backdrop = normalizeToHex(surface);
97
99
  return !!backdrop && contrastRatio(luminance, relativeLuminance(backdrop)) >= minContrastRatio;
98
100
  });
@@ -204,7 +206,7 @@ export function resolveChartPalette(input: ResolveChartPaletteInput): ChartPalet
204
206
  const fallbackIdentity = scheme === "dark" ? Colors.violetDark.violet10 : Colors.violet.violet9;
205
207
  const single =
206
208
  [input.identitySolid, ...(input.identityCandidates ?? [])].find((candidate) =>
207
- readableIdentity(candidate, scheme),
209
+ readableIdentity(candidate, scheme, input.surfaces ?? []),
208
210
  ) ?? fallbackIdentity;
209
211
  const identityHsl = colorToHsl(single) as Hsl;
210
212
 
@@ -229,7 +231,7 @@ export function resolveChartPalette(input: ResolveChartPaletteInput): ChartPalet
229
231
  /**
230
232
  * The accent solids ordered nearest-first in luminance to `identity`, so a
231
233
  * chart identity that misses the 3:1 mark floor on the reference cards falls
232
- * to the next accent step before a neutral one. `$accentBackground` is held
234
+ * to the next accent step before a neutral one. The accent solid is held
233
235
  * to the text floor (MPO-448), which in dark lands it one step deeper than
234
236
  * the marks need; the chart keeps the accent channel on the step beside it.
235
237
  */
@@ -256,7 +258,7 @@ export function accentIdentityCandidates(
256
258
  *
257
259
  * Reads scheme and tint from the resolved theme name and the identity solid
258
260
  * from the active theme (`$color9` under a tint — the re-ramped neutral
259
- * solid — otherwise `$accentBackground`). Chart chrome MUST take its default
261
+ * solid — otherwise the accent solid). Chart chrome MUST take its default
260
262
  * series colors from this hook; explicit `color`/`colors` props on chart
261
263
  * data are user values and pass through untouched.
262
264
  */
@@ -268,6 +270,8 @@ export function useChartPalette(): ChartPalette {
268
270
  const isTint = nameParts.length > 1 && tintHueNames.has(nameParts[1]);
269
271
  const color9 = theme.color9?.val;
270
272
  const accent = theme.accentBackground?.val;
273
+ const page = theme.color2?.val;
274
+ const card = theme.color3?.val;
271
275
  const identitySolid = isTint ? (color9 ?? accent) : (accent ?? color9);
272
276
  const color10 = theme.color10?.val;
273
277
  const color11 = theme.color11?.val;
@@ -283,8 +287,9 @@ export function useChartPalette(): ChartPalette {
283
287
  color10,
284
288
  color11,
285
289
  ],
290
+ surfaces: [page, card],
286
291
  }),
287
292
  // eslint-disable-next-line react-hooks/exhaustive-deps
288
- [scheme, identitySolid, isTint, accentKey, color10, color11],
293
+ [scheme, identitySolid, isTint, accentKey, color10, color11, page, card],
289
294
  );
290
295
  }
@@ -382,34 +382,21 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
382
382
  resolveThemeContrast(themes, { theme, foreground: "color", background: "background" }),
383
383
  ),
384
384
  );
385
- expect(reports.map((report) => report.ratio)).toEqual([
386
- expect.closeTo(7.31, 2),
387
- expect.closeTo(7.61, 2),
388
- ]);
385
+ expect(reports).toHaveLength(2);
386
+ expect(reports.every((report) => report.ratio >= aaTextContrastRatio)).toBe(true);
389
387
  });
390
388
 
391
- it("a colour pair below the floor FAILS", () => {
392
- // MPO-48 measured the dark accent toast action pill on the page ground.
393
- // Built: dark_accent.backgroundHover is #46349d; dark.background is #161519.
389
+ it("a colour pair below the floor fails", () => {
394
390
  expect(() =>
395
391
  assertContrast({
396
- foreground: themes.dark_accent.backgroundHover,
397
- background: themes.dark.background,
398
- label: "MPO-48 dark accent toast action",
392
+ foreground: themes.light.color3,
393
+ background: themes.light.background,
394
+ label: "low-contrast ink",
399
395
  }),
400
- ).toThrow(/MPO-48 dark accent toast action: #46349d on #161519 = 1\.937[0-9]+:1/);
401
-
402
- // MPO-40's warning-on-its-own-color1 arm used to sit here as the second
403
- // negative control at 4.44:1. The `intentInkFloor` step closed it — the
404
- // same pair now measures 4.71:1 — so it can no longer prove the trip-wire
405
- // bites, and it moved to the passing assertion below rather than being
406
- // deleted. MPO-48 above is still a live miss and still carries this test.
396
+ ).toThrow(/low-contrast ink/);
407
397
  });
408
398
 
409
- it("MPO-40's warning ink now clears the floor on its own color1 too", () => {
410
- // The pair that used to be the negative control. The palette step was
411
- // computed against the BASE color2 (#f9f9fa, the strictest ground), and
412
- // this checks the intent's own surface came along with it. Was 4.44.
399
+ it("warning ink clears the floor on its own color1 too", () => {
413
400
  const [report] = assertContrast(
414
401
  resolveThemeContrast(themes, {
415
402
  theme: "light_warning",
@@ -417,47 +404,42 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
417
404
  background: "color1",
418
405
  }),
419
406
  );
420
- expect(report.ratio).toBeCloseTo(4.66, 2);
407
+ expect(report.ratio).toBeGreaterThanOrEqual(aaTextContrastRatio);
421
408
  });
422
409
 
423
- it("current light_accent color-on-background clears the 3:1 non-text floor", () => {
424
- // Parent accent is 3.96:1 — under AA, over the large-text/UI floor. The
425
- // Button pair (asserted above) is the AA text contract; this pair is the
426
- // trip-wire's control so the old pale-lavender step is the thing that fails.
427
- const reports = assertContrast(
428
- resolveThemeContrast(themes, {
429
- theme: "light_accent",
430
- foreground: "color",
431
- background: "background",
432
- floor: minContrastRatio,
433
- }),
434
- );
435
- expect(reports[0].ratio).toBeCloseTo(3.96, 2);
436
- expect(reports[0].pass).toBe(true);
410
+ it("the inverted-base accent text clears AA on its background", () => {
411
+ for (const scheme of ["light", "dark"]) {
412
+ const [report] = assertContrast(
413
+ resolveThemeContrast(themes, {
414
+ theme: `${scheme}_accent`,
415
+ foreground: "color",
416
+ background: "background",
417
+ }),
418
+ );
419
+ expect(report.ratio).toBeGreaterThanOrEqual(aaTextContrastRatio);
420
+ }
437
421
  });
438
422
 
439
- it("re-introducing the old accent lightPalette[0] fails the test", () => {
440
- // The pale-lavender step that shipped a ~1.17:1 CTA. Swapping only [0]
441
- // leaves Button (palette[3]) alone and drops light_accent.background.
423
+ it("a pale accent page fails the contrast guard", () => {
424
+ const palette = [...defaultAccentTheme.lightPalette];
425
+ palette[1] = defaultAccentTheme.lightPalette[11];
442
426
  const broken = createThemesBuilder(
443
427
  defaultBaseTheme,
444
428
  {
445
429
  ...defaultAccentTheme,
446
- lightPalette: ["hsla(250, 50%, 95%, 1)", ...defaultAccentTheme.lightPalette.slice(1)],
430
+ lightPalette: palette,
447
431
  },
448
432
  defaultBuilderOptions,
449
- ).themes() as Record<string, Record<string, string>>;
450
-
433
+ ).themes();
451
434
  expect(() =>
452
435
  assertContrast(
453
436
  resolveThemeContrast(broken, {
454
437
  theme: "light_accent",
455
438
  foreground: "color",
456
439
  background: "background",
457
- floor: minContrastRatio,
458
440
  }),
459
441
  ),
460
- ).toThrow(/light_accent: color on background: #f9f8fc on #eeecf9 = 1\.103[0-9]+:1/);
442
+ ).toThrow(/light_accent: color on background/);
461
443
  });
462
444
  });
463
445
 
@@ -486,23 +468,6 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
486
468
  ),
487
469
  );
488
470
 
489
- /**
490
- * MPO-40 measured the outlined intent label on the gallery harness ground
491
- * and recorded warning 4.34:1 and success 4.48:1. That ground is the BASE
492
- * theme's `color2` (#f9f9fa) — NOT `background`/`color1`, which are both
493
- * #ffffff and where the same two pairs cleared the floor even unfixed. The
494
- * miss was therefore surface-specific: an outlined warning or success Button
495
- * only went unreadable once it sat on a color2 surface (a card, a striped
496
- * row, the gallery ground). Measuring against the plain page is exactly how
497
- * a check reports a pass on a control that is failing.
498
- *
499
- * Both are CLOSED by the `intentInkFloor` step in `createThemes.ts`, which
500
- * darkens light `yellow11` and `green11` to the nearest hue-holding value
501
- * that clears the floor on `color2`. The exemption table that used to stand
502
- * here retired itself exactly as designed: the step moved, its pinned ratios
503
- * stopped matching, and the entries had to come out. There is now no
504
- * exemption to rot.
505
- */
506
471
  it("sweeps every intent x base surface x scheme — 18 pairs, none dropped", () => {
507
472
  expect(intentInkPairs).toHaveLength(18);
508
473
  });
@@ -516,9 +481,6 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
516
481
  });
517
482
 
518
483
  it("pins the two MPO-40 stops on color2, the ground they used to fail", () => {
519
- // The regression guard proper: color2 is the STRICTEST base surface, so a
520
- // future palette move that quietly re-lightens either stop lands here
521
- // first. Was 4.34 / 4.48 against a 4.5 floor.
522
484
  const reports = assertContrast(
523
485
  (["warning", "success"] as const).map((intent) => ({
524
486
  foreground: themes[`light_${intent}`].color11,
@@ -527,14 +489,12 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
527
489
  })),
528
490
  );
529
491
  expect(reports.map((report) => report.ratio)).toEqual([
530
- expect.closeTo(4.56, 2),
531
- expect.closeTo(4.55, 2),
492
+ expect.closeTo(4.54, 2),
493
+ expect.closeTo(4.53, 2),
532
494
  ]);
533
495
  });
534
496
 
535
497
  it("the same warning/success ink still clears the floor on background and color1", () => {
536
- // Locks the surface-specificity so a future change cannot be signed off by
537
- // re-measuring against the white page. Was 4.57 / 4.72.
538
498
  const reports = assertContrast(
539
499
  (["warning", "success"] as const).flatMap((intent) =>
540
500
  (["background", "color1"] as const).map((surface) => ({
@@ -545,10 +505,10 @@ describe("intent color11 on the base page surfaces (MPO-40)", () => {
545
505
  ),
546
506
  );
547
507
  expect(reports.map((report) => report.ratio)).toEqual([
548
- expect.closeTo(4.8, 2),
549
- expect.closeTo(4.8, 2),
550
- expect.closeTo(4.79, 2),
551
- expect.closeTo(4.79, 2),
508
+ expect.closeTo(4.54, 2),
509
+ expect.closeTo(4.87, 2),
510
+ expect.closeTo(4.53, 2),
511
+ expect.closeTo(4.85, 2),
552
512
  ]);
553
513
  });
554
514
  });
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
2
  import { defaultKnobs, type Knobs } from "./knobs";
3
3
  import {
4
+ sanitizeOverrides,
4
5
  splitCookieValue,
5
6
  joinSplitCookieValues,
7
+ isKnobKey,
6
8
  readOverridesCookie,
7
9
  readPresetCookie,
8
10
  writePresetCookie,
@@ -78,7 +80,7 @@ describe("readOverridesCookie", () => {
78
80
  });
79
81
 
80
82
  it("reads and joins multiple chunks", () => {
81
- const overrides: Partial<Knobs> = { fillStyle: "outlined", size: "large" };
83
+ const overrides: Partial<Knobs> = { fillStyle: "outlined", elevation: "large" };
82
84
  const json = JSON.stringify(overrides);
83
85
  const mid = Math.floor(json.length / 2);
84
86
  const cookies: Record<string, string> = {
@@ -110,12 +112,12 @@ describe("readOverridesCookie", () => {
110
112
  // JSON.parse threw, and overrides silently resolved to {} app-wide.
111
113
  describe("readOverridesCookie with universal-cookie auto-parsed values", () => {
112
114
  it("accepts an already-parsed object cookie value (universal-cookie shape)", () => {
113
- const overrides: Partial<Knobs> = { borderRadius: "none", space: "small" };
115
+ const overrides: Partial<Knobs> = { borderRadius: "none", textAccent: "low" };
114
116
  expect(readOverridesCookie({ "mp.ov.0": overrides })).toEqual(overrides);
115
117
  });
116
118
 
117
119
  it("accepts a raw JSON string cookie value (document.cookie / SSR shape)", () => {
118
- const overrides: Partial<Knobs> = { borderRadius: "none", space: "small" };
120
+ const overrides: Partial<Knobs> = { borderRadius: "none", textAccent: "low" };
119
121
  expect(readOverridesCookie({ "mp.ov.0": JSON.stringify(overrides) })).toEqual(overrides);
120
122
  });
121
123
 
@@ -125,7 +127,7 @@ describe("readOverridesCookie with universal-cookie auto-parsed values", () => {
125
127
  });
126
128
 
127
129
  it("round-trips writeOverridesCookie output through an auto-parsing jar", () => {
128
- const overrides: Partial<Knobs> = { density: "compact", fillStyle: "outlined" };
130
+ const overrides: Partial<Knobs> = { elevation: "none", fillStyle: "outlined" };
129
131
  const { toSet } = writeOverridesCookie(overrides);
130
132
  // Simulate universal-cookie readCookie: JSON.parse succeeds on the
131
133
  // single-chunk value, so the jar hands back an object.
@@ -135,7 +137,7 @@ describe("readOverridesCookie with universal-cookie auto-parsed values", () => {
135
137
 
136
138
  it("round-trips oversized multi-chunk payloads through an auto-parsing jar", () => {
137
139
  const bigValue = "x".repeat(8000);
138
- const overrides = { animation: bigValue } as unknown as Partial<Knobs>;
140
+ const overrides = { headingFont: bigValue } as unknown as Partial<Knobs>;
139
141
  const { toSet } = writeOverridesCookie(overrides);
140
142
  expect(toSet.length).toBeGreaterThan(1);
141
143
  // universal-cookie leaves each chunk a string: a JSON fragment throws in
@@ -239,9 +241,9 @@ describe("computeOverrides", () => {
239
241
  });
240
242
 
241
243
  it("returns only changed keys", () => {
242
- const current: Knobs = { ...defaultKnobs, fillStyle: "outlined", size: "large" };
244
+ const current: Knobs = { ...defaultKnobs, fillStyle: "outlined", elevation: "large" };
243
245
  const overrides = computeOverrides(defaultKnobs, current);
244
- expect(overrides).toEqual({ fillStyle: "outlined", size: "large" });
246
+ expect(overrides).toEqual({ fillStyle: "outlined", elevation: "large" });
245
247
  });
246
248
 
247
249
  it("does not include unchanged keys", () => {
@@ -250,17 +252,22 @@ describe("computeOverrides", () => {
250
252
  expect(Object.keys(overrides)).toEqual(["fillStyle"]);
251
253
  });
252
254
 
253
- it("persists density override via mp.ov.* pattern", () => {
254
- const current: Knobs = { ...defaultKnobs, density: "compact" };
255
- const overrides = computeOverrides(defaultKnobs, current);
256
- expect(overrides).toEqual({ density: "compact" });
257
-
258
- const { toSet } = writeOverridesCookie(overrides);
259
- expect(toSet[0].key).toBe("mp.ov.0");
260
- expect(JSON.parse(toSet[0].value)).toEqual({ density: "compact" });
261
-
262
- const roundtrip = readOverridesCookie({ "mp.ov.0": toSet[0].value });
263
- expect(roundtrip).toEqual({ density: "compact" });
255
+ it("drops a stale size / space / density override a cookie still carries", () => {
256
+ const stale = JSON.stringify({
257
+ size: "large",
258
+ space: "small",
259
+ density: "compact",
260
+ fillStyle: "outlined",
261
+ hover: { space: "large", elevation: "medium" },
262
+ });
263
+ expect(readOverridesCookie({ "mp.ov.0": stale })).toEqual({
264
+ fillStyle: "outlined",
265
+ hover: { elevation: "medium" },
266
+ });
267
+ expect(isKnobKey("density")).toBe(false);
268
+ expect(isKnobKey("space")).toBe(false);
269
+ expect(isKnobKey("size")).toBe(false);
270
+ expect(isKnobKey("borderRadius")).toBe(true);
264
271
  });
265
272
 
266
273
  it("persists house-decision overrides via mp.ov.* pattern", () => {
@@ -299,3 +306,27 @@ describe("getCookieWatchList", () => {
299
306
  expect(getCookieWatchList()).toHaveLength(11);
300
307
  });
301
308
  });
309
+
310
+ describe("persisted animation knobs", () => {
311
+ it.each([
312
+ ["tooltip", "slow"],
313
+ ["snappy", "quickest"],
314
+ ["gentle", "slow"],
315
+ ["quick", "quick"],
316
+ ["none", "none"],
317
+ ["superLazy", "superLazy"],
318
+ ])("normalizes %s to %s", (stored, expected) => {
319
+ expect(sanitizeOverrides({ animation: stored })).toEqual({ animation: expected });
320
+ expect(readOverridesCookie({ "mp.ov.0": JSON.stringify({ animation: stored }) })).toEqual({
321
+ animation: expected,
322
+ });
323
+ });
324
+ it.each(["100ms", "ease", "unknown", 100, null, { hover: "quick" }])(
325
+ "drops unknown animation %s",
326
+ (animation) => {
327
+ expect(sanitizeOverrides({ animation, borderRadius: "small" })).toEqual({
328
+ borderRadius: "small",
329
+ });
330
+ },
331
+ );
332
+ });
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { getCookie, setCookie, deleteCookie } from "@multiplatform.one/platform";
14
- import type { Knobs } from "./knobs";
14
+ import { type Knobs, type StateKnobs, defaultKnobs, normalizeAnimationKnob } from "./knobs";
15
15
 
16
16
  // ---------------------------------------------------------------------------
17
17
  // Cookie key constants
@@ -69,25 +69,54 @@ function chunkKey(index: number): string {
69
69
  // Cookie read helpers (work with raw cookie objects from react-cookie)
70
70
  // ---------------------------------------------------------------------------
71
71
 
72
+ /**
73
+ * The knob names an overrides payload may carry; anything else is dropped,
74
+ * including the retired `size` / `space` / `density` a stale cookie holds.
75
+ */
76
+ const knobKeys = new Set<string>(Object.keys(defaultKnobs));
77
+
78
+ /** True when `key` names a live knob (stale and foreign names are not). */
79
+ export function isKnobKey(key: string): key is keyof Knobs {
80
+ return knobKeys.has(key);
81
+ }
82
+
83
+ const stateKnobKeys = new Set<string>([
84
+ "fillStyle",
85
+ "borderRadius",
86
+ "borderWidth",
87
+ "elevation",
88
+ "textAccent",
89
+ "headingFont",
90
+ "bodyFont",
91
+ "fontWeight",
92
+ ] satisfies (keyof StateKnobs)[]);
93
+
72
94
  /**
73
95
  * Shape-validate a parsed overrides payload before it reaches knob
74
96
  * resolution: keep knob-shaped entries — string values plus one-level
75
- * StateKnobs-style objects of string values (`hover`, `press`, …) — and drop
76
- * everything else, so a corrupt or foreign cookie can never spread garbage
77
- * (arrays, numbers, deep objects) into `{ ...preset.knobs, ...overrides }`.
97
+ * StateKnobs-style objects of string values (`hover`, `press`, …) — under a
98
+ * known knob name, and drop everything else, so a corrupt, foreign or stale
99
+ * cookie can never spread garbage (unknown keys, arrays, numbers, deep
100
+ * objects) into `{ ...preset.knobs, ...overrides }`.
78
101
  */
79
- function sanitizeOverrides(parsed: unknown): Partial<Knobs> {
102
+ export function sanitizeOverrides(parsed: unknown): Partial<Knobs> {
80
103
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
81
104
  return {};
82
105
  }
83
106
  const overrides: Record<string, unknown> = {};
84
107
  for (const [key, value] of Object.entries(parsed)) {
85
- if (typeof value === "string") {
108
+ if (!knobKeys.has(key)) continue;
109
+ if (key === "animation") {
110
+ const animation = normalizeAnimationKnob(value);
111
+ if (animation !== undefined) overrides.animation = animation;
112
+ } else if (typeof value === "string") {
86
113
  overrides[key] = value;
87
114
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
88
115
  const nested: Record<string, string> = {};
89
116
  for (const [stateKey, stateValue] of Object.entries(value)) {
90
- if (typeof stateValue === "string") nested[stateKey] = stateValue;
117
+ if (stateKnobKeys.has(stateKey) && typeof stateValue === "string") {
118
+ nested[stateKey] = stateValue;
119
+ }
91
120
  }
92
121
  overrides[key] = nested;
93
122
  }