@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
@@ -4,9 +4,6 @@ import {
4
4
  defaultKnobs,
5
5
  emptyStateKnobs,
6
6
  FillStyle,
7
- Space,
8
- Size,
9
- Density,
10
7
  CornerSmoothing,
11
8
  FieldLabelPlacement,
12
9
  RequiredMarking,
@@ -22,12 +19,21 @@ import type { KnobProps } from "./recipes";
22
19
  import { cornerSmoothClassName } from "./cornerSmoothing";
23
20
  import { defaultPreset, boldPreset, heroPreset } from "./presets";
24
21
  import {
25
- applyDensity,
26
22
  capContainerRadius,
23
+ capFieldRadius,
27
24
  containerCapProps,
25
+ labelFontSizeFor,
26
+ nestedControlPx,
27
+ paintedCardFill,
28
+ panelPaddingPx,
28
29
  resolveKnobs,
29
30
  resolvePageTitleScale,
31
+ spacingRoles,
32
+ sizedElevation,
30
33
  } from "./resolveKnobs";
34
+ import { getActiveScaleTokens, setActiveScaleTokens } from "./scale/activeScale";
35
+ import { generateScale } from "./scale/generateScale";
36
+ import { defaultScaleInputs } from "./scale/scaleInputs";
31
37
  import {
32
38
  normalizeToHex,
33
39
  relativeLuminance,
@@ -40,14 +46,29 @@ function knobsWith(overrides: Partial<Knobs>): Knobs {
40
46
  return { ...defaultKnobs, ...overrides };
41
47
  }
42
48
 
49
+ /** Resolve with another scale installed, then put the default scale back. */
50
+ function onScale<T>(control: number, type: number, read: () => T): T {
51
+ const previous = getActiveScaleTokens();
52
+ setActiveScaleTokens(generateScale({ control, type }).tokens);
53
+ try {
54
+ return read();
55
+ } finally {
56
+ setActiveScaleTokens(previous);
57
+ }
58
+ }
59
+
60
+ const defaultScaleTokens = generateScale(defaultScaleInputs).tokens;
61
+
43
62
  const expectedKnobPropKeys: (keyof KnobProps)[] = [
44
63
  "borderRadius",
45
64
  "borderRadiusNested",
46
65
  "borderRadiusOuter",
47
66
  "containerRadius",
67
+ "fieldRadius",
48
68
  "elevation",
49
69
  "overlayElevation",
50
70
  "elevationChrome",
71
+ "thumbShadow",
51
72
  "scheme",
52
73
  "surface",
53
74
  "cardSurface",
@@ -55,6 +76,7 @@ const expectedKnobPropKeys: (keyof KnobProps)[] = [
55
76
  "featureSurface",
56
77
  "inputSurface",
57
78
  "panelPadding",
79
+ "gapSm",
58
80
  "gap",
59
81
  "gapLg",
60
82
  "sizeToken",
@@ -63,9 +85,16 @@ const expectedKnobPropKeys: (keyof KnobProps)[] = [
63
85
  "control",
64
86
  "controlType",
65
87
  "controlIcon",
88
+ "menuRow",
89
+ "menuRowType",
90
+ "tab",
91
+ "tabType",
92
+ "tabHitSlop",
93
+ "bar",
66
94
  "heading",
67
95
  "pageTitle",
68
96
  "body",
97
+ "typography",
69
98
  "textWeight",
70
99
  "transition",
71
100
  "outlined",
@@ -73,9 +102,6 @@ const expectedKnobPropKeys: (keyof KnobProps)[] = [
73
102
  "textAccent",
74
103
  "textAccentColor",
75
104
  "inputBackground",
76
- "space",
77
- "size",
78
- "density",
79
105
  "fieldLabelPlacement",
80
106
  "requiredMarking",
81
107
  "tableZebra",
@@ -160,14 +186,49 @@ describe("resolveKnobs", () => {
160
186
  expect(a).toEqual(b);
161
187
  });
162
188
 
189
+ it("menuRow, tab and bar fragments read the recipe families and the scale", () => {
190
+ const desktop = resolveKnobs(defaultKnobs, undefined, "light", { touch: false }).knobProps;
191
+ expect(desktop.menuRow).toEqual({ minHeight: 36, paddingHorizontal: 12, gap: 8 });
192
+ expect(desktop.menuRowType).toEqual({ fontSize: 15 });
193
+ expect(desktop.tab.height).toBe(36);
194
+ expect(desktop.tabType).toEqual({ fontSize: 15 });
195
+ expect(desktop.tabHitSlop).toEqual({ top: 4, bottom: 4, left: 4, right: 4 });
196
+ expect(desktop.bar).toEqual({
197
+ frame: { paddingVertical: "$2", paddingHorizontal: "$4", minHeight: 52 },
198
+ sizeToken: "$3",
199
+ });
200
+ const touch = resolveKnobs(defaultKnobs, undefined, "light", { touch: true }).knobProps;
201
+ expect(touch.menuRow.minHeight).toBe(44);
202
+ expect(touch.tab.height).toBe(44);
203
+ expect(touch.tabHitSlop).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
204
+ });
205
+
163
206
  it("matches snapshot for defaultKnobs", () => {
164
207
  const resolved = resolveKnobs(defaultKnobs);
165
208
  expect(resolved).toMatchSnapshot();
166
209
  });
167
210
 
168
- // ── T1.1g — expectedKnobPropKeys includes "size" ────────
169
- it('expectedKnobPropKeys includes "size"', () => {
170
- expect(expectedKnobPropKeys).toContain("size");
211
+ it("knobProps carries no size, space or density channel (LC-100)", () => {
212
+ const { knobProps } = resolveKnobs(defaultKnobs);
213
+ for (const retired of ["size", "space", "density"]) {
214
+ expect(expectedKnobPropKeys).not.toContain(retired);
215
+ expect(knobProps).not.toHaveProperty(retired);
216
+ }
217
+ });
218
+
219
+ it("Knobs has no size, space or density member (LC-100)", () => {
220
+ // @ts-expect-error size is not a knob: a use site passes Tamagui's own size token
221
+ const size: Knobs["size"] = undefined;
222
+ // @ts-expect-error space is not a knob: spacing is the scale's token values
223
+ const space: Knobs["space"] = undefined;
224
+ // @ts-expect-error density is not a knob: a preset carries its own scale
225
+ const density: Knobs["density"] = undefined;
226
+ expect([size, space, density]).toEqual([undefined, undefined, undefined]);
227
+ for (const retired of ["size", "space", "density"]) {
228
+ expect(defaultKnobs).not.toHaveProperty(retired);
229
+ expect(defaultPreset.knobs).not.toHaveProperty(retired);
230
+ expect(boldPreset.knobs).not.toHaveProperty(retired);
231
+ }
171
232
  });
172
233
 
173
234
  // ── borderRadius ──────────────────────────────────────────
@@ -220,75 +281,83 @@ describe("resolveKnobs", () => {
220
281
 
221
282
  // ── elevation ──────────────────────────────────────────────
222
283
 
223
- describe("elevation knob", () => {
224
- it('maps "none" → undefined', () => {
225
- expect(resolveKnobs(knobsWith({ elevation: "none" })).knobProps.elevation).toBeUndefined();
226
- });
227
-
228
- it('maps "small" → undefined: controls paint no resting shadow at the default (MPO-96)', () => {
229
- const { knobProps } = resolveKnobs(knobsWith({ elevation: "small" }));
230
- expect(knobProps.elevation).toBeUndefined();
231
- expect(knobProps.elevationChrome).toEqual({});
232
- });
233
-
234
- it("overlay elevation at small is $2, not the control $1 token", () => {
235
- const { knobProps } = resolveKnobs(knobsWith({ elevation: "small" }));
236
- expect(knobProps.overlayElevation).toBe("$2");
237
- expect(knobProps.elevatedSurface.elevation).toBe("$2");
238
- expect(knobProps.elevatedSurface.elevation).not.toBe(knobProps.elevation);
239
- });
240
-
241
- it('maps "medium" → "$2"', () => {
242
- expect(resolveKnobs(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe("$2");
243
- });
244
-
245
- it('maps "large" → "$4"', () => {
246
- expect(resolveKnobs(knobsWith({ elevation: "large" })).knobProps.elevation).toBe("$4");
247
- });
248
-
249
- it("light scheme keeps overlay fill at $background and paints a black shadow", () => {
250
- const { knobProps } = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "light");
251
- expect(knobProps.scheme).toBe("light");
252
- expect(knobProps.elevatedSurface.backgroundColor).toBe("$background");
253
- expect(knobProps.cardSurface.backgroundColor).toBe("$background");
254
- expect(knobProps.inputBackground).toBe("$background");
255
- expect(knobProps.elevatedSurface.shadowColor).toBe("#000");
256
- expect(knobProps.elevatedSurface.shadowOpacity).toBe(0.12);
257
- expect(knobProps.elevationChrome.elevation).toBe(8);
258
- });
259
-
260
- it("dark scheme tints overlay/card/input up the existing ramp (LC-85)", () => {
261
- const none = resolveKnobs(knobsWith({ elevation: "none" }), undefined, "dark");
262
- const small = resolveKnobs(knobsWith({ elevation: "small" }), undefined, "dark");
263
- const large = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "dark");
264
- expect(none.knobProps.elevatedSurface.backgroundColor).toBe("$background");
265
- expect(small.knobProps.elevatedSurface.backgroundColor).toBe("$color2");
266
- expect(large.knobProps.elevatedSurface.backgroundColor).toBe("$color4");
267
- expect(large.knobProps.cardSurface.backgroundColor).toBe("$color4");
268
- expect(large.knobProps.inputBackground).toBe("$color5");
269
- expect(large.knobProps.elevatedSurface.shadowColor).toBe("$color12");
270
- expect(large.knobProps.elevatedSurface.shadowOpacity).toBe(0.12);
271
- expect(none.knobProps.elevationChrome).toEqual({});
284
+ describe("sizedElevation", () => {
285
+ it.each([
286
+ [26, 8, 15, 16],
287
+ [28, 8, 16, 16],
288
+ [36, 10, 20, 20],
289
+ [44, 12, 24, 24],
290
+ [52, 14, 28, 28],
291
+ ])("%i px uses the Tamagui formula", (px, offsetY, radius, android) => {
292
+ expect(sizedElevation(px)).toEqual({ offsetY, radius, android });
272
293
  });
294
+ });
273
295
 
274
- it("dark outlined inputs stay transparent — tint is for filled surfaces", () => {
275
- const { knobProps } = resolveKnobs(
276
- knobsWith({ elevation: "large", fillStyle: "outlined" }),
277
- undefined,
278
- "dark",
296
+ describe("elevation knob", () => {
297
+ for (const scheme of ["light", "dark"] as const) {
298
+ for (const [stop, key] of [
299
+ ["none", undefined],
300
+ ["small", undefined],
301
+ ["medium", "$2"],
302
+ ["large", "$4"],
303
+ ] as const) {
304
+ it(`${scheme} ${stop} paints the active size formula`, () => {
305
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: stop }), undefined, scheme);
306
+ expect(knobProps.elevation).toBe(key);
307
+ expect(knobProps.cardSurface.backgroundColor).toBe("$background");
308
+ expect(knobProps.elevatedSurface.backgroundColor).toBe(
309
+ scheme === "dark" ? "$color4" : "$background",
310
+ );
311
+ expect(knobProps.elevatedSurface.borderColor).toBe(
312
+ scheme === "dark" ? "$color6" : "$borderColor",
313
+ );
314
+ if (!key) {
315
+ expect(knobProps.elevationChrome).toEqual({});
316
+ return;
317
+ }
318
+ const { offsetY, radius, android } = sizedElevation(getActiveScaleTokens().size[key]);
319
+ expect(knobProps.elevationChrome).toEqual({
320
+ shadowColor: "$shadowColor",
321
+ shadowOffset: { width: 0, height: offsetY },
322
+ shadowRadius: radius,
323
+ shadowOpacity: 1,
324
+ elevation: android,
325
+ style: { boxShadow: `0px ${offsetY}px ${radius}px var(--shadowColor)` },
326
+ });
327
+ expect(knobProps.elevationChrome.shadowColor).not.toBe("$color12");
328
+ });
329
+ }
330
+ }
331
+ it("dark elevated fill is one palette step above the Card surface", async () => {
332
+ const { createDefaultThemeConfig } = await import("./createDefaultThemeConfig");
333
+ const themes = createDefaultThemeConfig().tamagui.themes;
334
+ const { knobProps } = resolveKnobs(defaultKnobs, undefined, "dark");
335
+ const palette = Array.from(
336
+ { length: 12 },
337
+ (_, index) => themes.dark[`color${index + 1}`].val,
279
338
  );
280
- expect(knobProps.inputBackground).toBe("transparent");
281
339
  expect(knobProps.elevatedSurface.backgroundColor).toBe("$color4");
340
+ const cardIndex = palette.indexOf(themes.dark_Card.background.val);
341
+ const overlayIndex = palette.indexOf(themes.dark.color4.val);
342
+ expect(cardIndex).toBeGreaterThanOrEqual(0);
343
+ expect(overlayIndex - cardIndex).toBe(1);
344
+ });
345
+
346
+ it("recomputes paint when the active scale changes", () => {
347
+ const before = resolveKnobs(knobsWith({ elevation: "large" })).knobProps;
348
+ onScale(48, 16, () => {
349
+ const after = resolveKnobs(knobsWith({ elevation: "large" })).knobProps;
350
+ const { offsetY, radius } = sizedElevation(getActiveScaleTokens().size.$4);
351
+ expect(after.elevationChrome.style?.boxShadow).toBe(
352
+ `0px ${offsetY}px ${radius}px var(--shadowColor)`,
353
+ );
354
+ expect(after.elevationChrome).not.toEqual(before.elevationChrome);
355
+ });
282
356
  });
283
357
  });
284
358
 
285
- // ── elevation on NATIVE (SB-N-04) ──────────────────────────
286
- // Android's RCTView `elevation` prop is a Double; a token string reaching a
287
- // plain RN View kills the ReactHost. Native must emit the numeric
288
- // tokens.size values Tamagui would resolve those tokens to.
289
-
290
359
  describe("elevation knob (native)", () => {
291
- it("emits RN-safe numbers, never token strings", async () => {
360
+ it("returns active scale numbers with the same paint in both schemes", async () => {
292
361
  vi.resetModules();
293
362
  vi.doMock("@multiplatform.one/platform", () => ({
294
363
  isWeb: false,
@@ -296,23 +365,57 @@ describe("resolveKnobs", () => {
296
365
  isWebTouchable: false,
297
366
  }));
298
367
  const { resolveKnobs: resolveNative } = await import("./resolveKnobs");
368
+ const active = await import("./scale/activeScale");
369
+ const previous = active.getActiveScaleTokens();
299
370
  try {
300
- expect(resolveNative(knobsWith({ elevation: "none" })).knobProps.elevation).toBeUndefined();
301
- const small = resolveNative(
302
- knobsWith({ elevation: "small", hover: { elevation: "medium" } }),
303
- );
304
- expect(small.knobProps.elevation).toBeUndefined();
305
- expect(small.knobProps.elevationChrome).toEqual({});
306
- expect(small.knobProps.overlayElevation).toBe(28);
307
- expect(small.knobProps.elevatedSurface.elevation).toBe(28);
308
- expect(small.knobProps.elevatedSurface.elevation).not.toBe(small.knobProps.elevation);
309
- expect(small.elevation.hoverKnobProps?.elevation).toBe(28);
310
- expect(resolveNative(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe(28);
311
- expect(resolveNative(knobsWith({ elevation: "large" })).knobProps.elevation).toBe(44);
312
- expect(
313
- resolveNative(knobsWith({ elevation: "large" })).knobProps.elevationChrome.elevation,
314
- ).toBe(8);
371
+ for (const control of [40, 48]) {
372
+ active.setActiveScaleTokens(generateScale({ control }).tokens);
373
+ for (const scheme of ["light", "dark"] as const) {
374
+ for (const [stop, key, overlay] of [
375
+ ["none", undefined, undefined],
376
+ ["small", undefined, "$4"],
377
+ ["medium", "$2", "$4.5"],
378
+ ["large", "$4", "$5"],
379
+ ] as const) {
380
+ const { knobProps } = resolveNative(
381
+ knobsWith({ elevation: stop }),
382
+ undefined,
383
+ scheme,
384
+ );
385
+ expect(knobProps.elevation).toBe(
386
+ key ? active.getActiveScaleTokens().size[key] : undefined,
387
+ );
388
+ expect(knobProps.overlayElevation).toBe(
389
+ overlay ? active.getActiveScaleTokens().size[overlay] : undefined,
390
+ );
391
+ expect(knobProps.elevatedSurface.elevation).toBe(knobProps.overlayElevation);
392
+ const state = resolveNative(
393
+ knobsWith({ hover: { elevation: stop } }),
394
+ undefined,
395
+ scheme,
396
+ );
397
+ expect(state.elevation.hoverKnobProps?.elevation).toBe(
398
+ key ? active.getActiveScaleTokens().size[key] : undefined,
399
+ );
400
+
401
+ expect(knobProps.elevationChrome.style).toBeUndefined();
402
+ if (key) {
403
+ const metrics = sizedElevation(active.getActiveScaleTokens().size[key]);
404
+ expect(knobProps.elevationChrome).toEqual({
405
+ shadowColor: "$shadowColor",
406
+ shadowOpacity: 1,
407
+ shadowOffset: { width: 0, height: metrics.offsetY },
408
+ shadowRadius: metrics.radius,
409
+ elevation: metrics.android,
410
+ });
411
+ } else expect(knobProps.elevationChrome).toEqual({});
412
+ expect(knobProps.thumbShadow.style).toBeUndefined();
413
+ expect(knobProps.thumbShadow.shadowColor).toBe("$shadowColor");
414
+ }
415
+ }
416
+ }
315
417
  } finally {
418
+ active.setActiveScaleTokens(previous);
316
419
  vi.doUnmock("@multiplatform.one/platform");
317
420
  vi.resetModules();
318
421
  }
@@ -320,48 +423,54 @@ describe("resolveKnobs", () => {
320
423
  });
321
424
 
322
425
  describe("overlay elevation ladder (MPO-99)", () => {
323
- const paint = (elevation: "small" | "medium" | "large", scheme: "light" | "dark") =>
324
- resolveKnobs(knobsWith({ elevation }), undefined, scheme).knobProps;
325
-
326
- it("overlay medium and large paint the reference popper and dialog shadows", () => {
327
- expect(paint("medium", "light").overlayElevation).toBe("$3");
328
- expect(paint("medium", "light").elevatedSurface.style?.boxShadow).toBe(
329
- "0 4px 8px rgba(0,0,0,0.10), 0 12px 32px rgba(0,0,0,0.08)",
330
- );
331
- expect(paint("large", "light").overlayElevation).toBe("$5");
332
- expect(paint("large", "light").elevatedSurface.style?.boxShadow).toBe(
333
- "0 12px 24px rgba(0,0,0,0.12)",
334
- );
335
- });
336
-
337
- it("overlay medium and large differ from control medium and large, in both schemes", () => {
338
- for (const scheme of ["light", "dark"] as const) {
339
- for (const stop of ["medium", "large"] as const) {
340
- const knobProps = paint(stop, scheme);
341
- expect(knobProps.elevatedSurface.style?.boxShadow).toBeTruthy();
342
- expect(knobProps.elevationChrome.style?.boxShadow).toBeTruthy();
426
+ for (const scheme of ["light", "dark"] as const) {
427
+ for (const [stop, key] of [
428
+ ["small", "$4"],
429
+ ["medium", "$4.5"],
430
+ ["large", "$5"],
431
+ ] as const) {
432
+ it(`${scheme} ${stop} uses its own overlay stop`, () => {
433
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: stop }), undefined, scheme);
434
+ const { offsetY, radius } = sizedElevation(getActiveScaleTokens().size[key]);
435
+ expect(knobProps.overlayElevation).toBe(key);
436
+ expect(knobProps.elevatedSurface.shadowColor).toBe("$shadowColor");
437
+ expect(knobProps.elevatedSurface.shadowColor).not.toBe("$color12");
438
+ expect(knobProps.elevatedSurface.shadowOpacity).toBe(1);
439
+ expect(knobProps.elevatedSurface.style?.boxShadow).toBe(
440
+ `0px ${offsetY}px ${radius}px var(--shadowColor)`,
441
+ );
343
442
  expect(knobProps.elevatedSurface.style?.boxShadow).not.toBe(
344
443
  knobProps.elevationChrome.style?.boxShadow,
345
444
  );
346
- }
445
+ });
347
446
  }
447
+ }
448
+ it("none has no overlay paint", () => {
449
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "none" }));
450
+ expect(knobProps.overlayElevation).toBeUndefined();
451
+ expect(knobProps.elevatedSurface.style).toBeUndefined();
452
+ expect(knobProps.elevatedSurface.shadowColor).toBeUndefined();
348
453
  });
454
+ });
349
455
 
350
- it("dark keeps each overlay stop's geometry and inverts the hue (LC-85)", () => {
351
- expect(paint("medium", "dark").elevatedSurface.style?.boxShadow).toBe(
352
- "0 4px 8px rgba(255,255,255,0.10), 0 12px 32px rgba(255,255,255,0.08)",
353
- );
354
- expect(paint("large", "dark").elevatedSurface.style?.boxShadow).toBe(
355
- "0 12px 24px rgba(255,255,255,0.12)",
356
- );
357
- });
358
-
359
- it("overlays keep a shadow at the default stop while controls go flat (MPO-96)", () => {
360
- const knobProps = paint("small", "light");
361
- expect(knobProps.elevationChrome).toEqual({});
362
- expect(knobProps.elevatedSurface.style?.boxShadow).toBe(
363
- "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
364
- );
456
+ describe("thumbShadow", () => {
457
+ it("paints the half-size stop at every elevation stop and follows the active scale", () => {
458
+ for (const control of [40, 48])
459
+ onScale(control, 16, () => {
460
+ for (const elevation of ["none", "small", "medium", "large"] as const) {
461
+ const { offsetY, radius, android } = sizedElevation(
462
+ getActiveScaleTokens().size["$0.5"],
463
+ );
464
+ expect(resolveKnobs(knobsWith({ elevation })).knobProps.thumbShadow).toEqual({
465
+ shadowColor: "$shadowColor",
466
+ shadowOffset: { width: 0, height: offsetY },
467
+ shadowOpacity: 1,
468
+ shadowRadius: radius,
469
+ elevation: android,
470
+ style: { boxShadow: `0px ${offsetY}px ${radius}px var(--shadowColor)` },
471
+ });
472
+ }
473
+ });
365
474
  });
366
475
  });
367
476
 
@@ -411,113 +520,143 @@ describe("resolveKnobs", () => {
411
520
  });
412
521
  });
413
522
 
414
- // ── space ──────────────────────────────────────────────
523
+ // ── spacing roles (SP-GAP, A9) ─────────────────────────────
415
524
 
416
- describe("space knob", () => {
417
- it('maps panelPadding "small" → "$3"', () => {
418
- const { knobProps } = resolveKnobs(knobsWith({ space: "small" }));
419
- expect(knobProps.panelPadding.padding).toBe("$3");
420
- });
421
-
422
- it('maps gap "large" → "$5"', () => {
423
- const { knobProps } = resolveKnobs(knobsWith({ space: "large" }));
424
- expect(knobProps.gap.gap).toBe("$5");
425
- });
426
-
427
- it("passes space value through to knobProps", () => {
428
- const { knobProps } = resolveKnobs(knobsWith({ space: "large" }));
429
- expect(knobProps.space).toBe("large");
430
- });
431
-
432
- // T2.1a — space "large" no longer changes sizeToken
433
- it("space does not affect sizeToken", () => {
434
- const { knobProps } = resolveKnobs(knobsWith({ space: "large", size: "medium" }));
435
- expect(knobProps.sizeToken).toBe("$4");
436
- });
437
-
438
- // T2.1b — space "small" still maps panelPadding regardless of size
439
- it('space "small" maps panelPadding → "$3" regardless of size', () => {
440
- const { knobProps } = resolveKnobs(knobsWith({ space: "small", size: "large" }));
441
- expect(knobProps.panelPadding.padding).toBe("$3");
442
- });
443
-
444
- // T2.1c — space "large" still maps gapLg → "$6" regardless of size
445
- it('space "large" maps gapLg → "$6" regardless of size', () => {
446
- const { knobProps } = resolveKnobs(knobsWith({ space: "large", size: "small" }));
447
- expect(knobProps.gapLg.gap).toBe("$6");
448
- });
449
- });
450
-
451
- // ── size ──────────────────────────────────────────────────
452
-
453
- describe("size knob", () => {
454
- // T1.1b — size "large" maps sizeToken → "$5"
455
- it('maps sizeToken "large" → "$5"', () => {
456
- const { knobProps } = resolveKnobs(knobsWith({ size: "large" }));
457
- expect(knobProps.sizeToken).toBe("$5");
458
- });
459
-
460
- // T1.1c — size "small" maps sizeToken → "$3"
461
- it('maps sizeToken "small" → "$3"', () => {
462
- const { knobProps } = resolveKnobs(knobsWith({ size: "small" }));
463
- expect(knobProps.sizeToken).toBe("$3");
464
- });
465
-
466
- // T1.1e — knobProps.size passthrough
467
- it("passes size value through to knobProps", () => {
468
- for (const val of Object.values(Size)) {
469
- const { knobProps } = resolveKnobs(knobsWith({ size: val }));
470
- expect(knobProps.size).toBe(val);
525
+ describe("spacing roles (SP-GAP)", () => {
526
+ it("defaults: gapSm $2, gap $3, gapLg $5, panelPadding $4, sizeToken $true", () => {
527
+ const { knobProps } = resolveKnobs(defaultKnobs);
528
+ expect(knobProps.gapSm).toEqual({ gap: "$2" });
529
+ expect(knobProps.gap).toEqual({ gap: "$3" });
530
+ expect(knobProps.gapLg).toEqual({ gap: "$5" });
531
+ expect(knobProps.panelPadding).toEqual({ padding: "$4" });
532
+ expect(knobProps.sizeToken).toBe("$true");
533
+ });
534
+
535
+ it("the roles are the same keys under every knob value: no knob moves them", () => {
536
+ const variants: Partial<Knobs>[] = [
537
+ { borderRadius: "full" },
538
+ { borderRadius: "none" },
539
+ { fillStyle: "outlined" },
540
+ { elevation: "large" },
541
+ { fontWeight: "bold" },
542
+ { textAccent: "low" },
543
+ { hover: { borderWidth: "large" } },
544
+ ];
545
+ for (const variant of variants) {
546
+ const { knobProps } = resolveKnobs(knobsWith(variant));
547
+ expect(knobProps.gapSm.gap, JSON.stringify(variant)).toBe(spacingRoles.gapSm);
548
+ expect(knobProps.gap.gap, JSON.stringify(variant)).toBe(spacingRoles.gap);
549
+ expect(knobProps.gapLg.gap, JSON.stringify(variant)).toBe(spacingRoles.gapLg);
550
+ expect(knobProps.panelPadding.padding, JSON.stringify(variant)).toBe(
551
+ spacingRoles.panelPadding,
552
+ );
471
553
  }
472
554
  });
473
555
 
474
- // T1.1f — independent axes: size "large" + space "small"
475
- it('size "large" + space "small" → sizeToken "$5" AND gap "$2"', () => {
476
- const { knobProps } = resolveKnobs(knobsWith({ size: "large", space: "small" }));
477
- expect(knobProps.sizeToken).toBe("$5");
478
- expect(knobProps.gap.gap).toBe("$2");
479
- });
480
-
481
- // T2.1e — changing size does NOT affect panelPadding, gap, or gapLg
482
- it("changing size does not affect panelPadding, gap, or gapLg", () => {
483
- const baseline = resolveKnobs(knobsWith({ size: "medium", space: "medium" }));
484
- for (const sizeVal of Object.values(Size)) {
485
- const { knobProps } = resolveKnobs(knobsWith({ size: sizeVal, space: "medium" }));
486
- expect(knobProps.panelPadding).toEqual(baseline.knobProps.panelPadding);
487
- expect(knobProps.gap).toEqual(baseline.knobProps.gap);
488
- expect(knobProps.gapLg).toEqual(baseline.knobProps.gapLg);
556
+ it("tight < within-group < inset < between-groups on the default scale (6 / 12 / 16 / 21)", () => {
557
+ const px = (key: string) => defaultScaleTokens.space[key as "$2"];
558
+ expect(px(spacingRoles.gapSm)).toBe(6);
559
+ expect(px(spacingRoles.gap)).toBe(12);
560
+ expect(px(spacingRoles.panelPadding)).toBe(16);
561
+ expect(px(spacingRoles.gapLg)).toBe(21);
562
+ });
563
+
564
+ it("a preset scale changes the roles' px, never their keys", () => {
565
+ for (const [control, type] of [
566
+ [36, 14],
567
+ [48, 16],
568
+ ] as const) {
569
+ const { knobProps, padding } = onScale(control, type, () => ({
570
+ knobProps: resolveKnobs(defaultKnobs).knobProps,
571
+ padding: panelPaddingPx(),
572
+ }));
573
+ const scale = generateScale({ control, type }).tokens;
574
+ expect(knobProps.gap).toEqual({ gap: "$3" });
575
+ expect(knobProps.panelPadding).toEqual({ padding: "$4" });
576
+ expect(padding, `${control}/${type}`).toBe(scale.space.$4);
577
+ expect(padding, `${control}/${type}`).not.toBe(defaultScaleTokens.space.$4);
489
578
  }
490
579
  });
491
580
 
492
- // T2.1f — default knobs produce identical output to prior default
493
- it("default knobs (size+space both medium) produce consistent output", () => {
494
- const resolved = resolveKnobs(defaultKnobs);
495
- expect(resolved.knobProps.sizeToken).toBe("$4");
496
- expect(resolved.knobProps.gap.gap).toBe("$4");
497
- expect(resolved.knobProps.panelPadding.padding).toBe("$4");
498
- expect(resolved.knobProps.gapLg.gap).toBe("$5");
581
+ it("state knobs carry no padding: space is not a state channel", () => {
582
+ const { control } = resolveKnobs(knobsWith({ hover: { borderWidth: "large" } }));
583
+ expect(control.hoverKnobProps).not.toHaveProperty("padding");
499
584
  });
585
+ });
500
586
 
501
- it("fills control / controlType / controlIcon from the size recipe", () => {
587
+ // ── size token (environment.sizeToken) ────────────────────
588
+
589
+ describe("size token", () => {
590
+ it("fills control / controlType / controlIcon from the $true recipe by default", () => {
502
591
  const { knobProps } = resolveKnobs(defaultKnobs);
503
592
  expect(knobProps.control).toEqual({
504
- height: 44,
505
- paddingHorizontal: 18,
506
- gap: 9,
593
+ height: 40,
594
+ paddingHorizontal: 16,
595
+ gap: 8,
507
596
  });
508
- expect(knobProps.controlType).toEqual({ fontSize: 14 });
597
+ expect(knobProps.controlType).toEqual({ fontSize: 15 });
509
598
  expect(knobProps.controlIcon).toEqual({ width: 16, height: 16 });
510
599
  expect(knobProps.control).not.toHaveProperty("borderRadius");
511
600
  expect(knobProps.controlType).not.toHaveProperty("borderRadius");
512
601
  expect(knobProps.controlIcon).not.toHaveProperty("borderRadius");
513
602
  });
514
603
 
515
- it("small and large share the same borderRadius fragment", () => {
516
- const small = resolveKnobs(knobsWith({ size: "small" }));
517
- const large = resolveKnobs(knobsWith({ size: "large" }));
518
- expect(small.knobProps.control.height).toBe(36);
519
- expect(large.knobProps.control.height).toBe(52);
520
- expect(small.knobProps.borderRadius).toEqual(large.knobProps.borderRadius);
604
+ it("environment.sizeToken picks the recipe at that $size key", () => {
605
+ const small = resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "$3" });
606
+ const large = resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "$5" });
607
+ expect(small.knobProps.sizeToken).toBe("$3");
608
+ expect(small.knobProps.control.height).toBe(defaultScaleTokens.size.$3);
609
+ expect(large.knobProps.sizeToken).toBe("$5");
610
+ expect(large.knobProps.control.height).toBe(defaultScaleTokens.size.$5);
611
+ });
612
+
613
+ it("a size token is not a $size key falls back to $true", () => {
614
+ expect(
615
+ resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "40" }).knobProps.sizeToken,
616
+ ).toBe("$true");
617
+ expect(
618
+ resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "$medium" }).knobProps.control
619
+ .height,
620
+ ).toBe(40);
621
+ });
622
+
623
+ it("the size token never moves the spacing roles or the radius fragment", () => {
624
+ const baseline = resolveKnobs(defaultKnobs).knobProps;
625
+ for (const sizeToken of ["$2", "$3", "$4", "$5", "$6"]) {
626
+ const { knobProps } = resolveKnobs(defaultKnobs, undefined, "light", { sizeToken });
627
+ expect(knobProps.gapSm, sizeToken).toEqual(baseline.gapSm);
628
+ expect(knobProps.gap, sizeToken).toEqual(baseline.gap);
629
+ expect(knobProps.gapLg, sizeToken).toEqual(baseline.gapLg);
630
+ expect(knobProps.panelPadding, sizeToken).toEqual(baseline.panelPadding);
631
+ expect(knobProps.borderRadius, sizeToken).toEqual(baseline.borderRadius);
632
+ }
633
+ });
634
+
635
+ it("label type is the body key at the size key, rounding half steps up", () => {
636
+ expect(labelFontSizeFor("$true")).toBe("$true");
637
+ expect(labelFontSizeFor("$4")).toBe("$4");
638
+ expect(labelFontSizeFor("$3")).toBe("$3");
639
+ expect(labelFontSizeFor("$3.5")).toBe("$4");
640
+ expect(labelFontSizeFor("$5")).toBe("$5");
641
+ expect(labelFontSizeFor("$1")).toBe("$1");
642
+ const { knobProps } = resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "$3" });
643
+ expect(knobProps.label.fontSize).toBe("$3");
644
+ expect(resolveKnobs(defaultKnobs).knobProps.label.fontSize).toBe(
645
+ resolveKnobs(defaultKnobs).knobProps.sizeToken,
646
+ );
647
+ });
648
+
649
+ it("nestedControl is 32/44 of the size token's $size, below the floor, press restored by hitSlop", () => {
650
+ const medium = resolveKnobs(defaultKnobs).knobProps.nestedControl;
651
+ expect(medium.px).toBe(nestedControlPx("$true"));
652
+ expect(medium.width).toBe(nestedControlPx("$true"));
653
+ expect(medium.maxWidth).toBe(medium.px);
654
+ expect(medium.hitSlop).toEqual({ top: 8, bottom: 8, left: 8, right: 8 });
655
+ const small = resolveKnobs(defaultKnobs, undefined, "light", { sizeToken: "$3" }).knobProps
656
+ .nestedControl;
657
+ expect(small.px).toBe(Math.round((defaultScaleTokens.size.$3 * 32) / 44));
658
+ expect(nestedControlPx("$5")).toBe(Math.round((defaultScaleTokens.size.$5 * 32) / 44));
659
+ expect(onScale(36, 14, () => nestedControlPx("$true"))).toBe(Math.round((36 * 32) / 44));
521
660
  });
522
661
  });
523
662
 
@@ -528,21 +667,21 @@ describe("resolveKnobs", () => {
528
667
  const { knobProps } = resolveKnobs(defaultKnobs);
529
668
  expect(knobProps.cardSurface).toEqual({
530
669
  backgroundColor: "$background",
531
- borderRadius: "$4",
670
+ borderRadius: "$6",
532
671
  padding: "$4",
533
672
  });
534
673
  expect(knobProps.cardSurface).not.toHaveProperty("borderWidth");
535
674
  expect(knobProps.cardSurface).not.toHaveProperty("elevation");
536
675
  });
537
676
 
538
- it("cardSurface tracks borderRadius and space knobs (cap bites: large radius 16px > small padding 13px)", () => {
539
- const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "large", space: "small" }));
540
- expect(knobProps.cardSurface.borderRadius).toBe(13);
541
- expect(knobProps.cardSurface.padding).toBe("$3");
677
+ it("cardSurface tracks the borderRadius knob (cap bites at full: 44px > the 16px panelPadding)", () => {
678
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "full" }));
679
+ expect(knobProps.cardSurface.borderRadius).toBe(16);
680
+ expect(knobProps.cardSurface.padding).toBe("$4");
542
681
  });
543
682
 
544
683
  it("cardSurface keeps the radius token while it fits the padding", () => {
545
- const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "large", space: "medium" }));
684
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: "medium" }));
546
685
  expect(knobProps.cardSurface.borderRadius).toBe("$6");
547
686
  });
548
687
 
@@ -578,23 +717,16 @@ describe("resolveKnobs", () => {
578
717
  expect(knobProps.featureSurface).not.toHaveProperty("borderWidth");
579
718
  });
580
719
 
581
- it("featureSurface keeps $10 / $6 at the default knobs and every other stop moves them (MPO-21 GL-59)", () => {
720
+ it("featureSurface keeps $10 / $6 at the default knobs and every other radius stop moves the radius (MPO-21 GL-59)", () => {
582
721
  expect(defaultKnobs.borderRadius).toBe("medium");
583
- expect(defaultKnobs.space).toBe("medium");
584
722
  for (const borderRadius of ["none", "small", "large", "full"] as const) {
585
- expect(
586
- resolveKnobs(knobsWith({ borderRadius })).knobProps.featureSurface.borderRadius,
587
- borderRadius,
588
- ).not.toBe("$10");
589
- }
590
- for (const space of ["small", "large"] as const) {
591
- expect(resolveKnobs(knobsWith({ space })).knobProps.featureSurface.padding, space).not.toBe(
592
- "$6",
593
- );
723
+ const { featureSurface } = resolveKnobs(knobsWith({ borderRadius })).knobProps;
724
+ expect(featureSurface.borderRadius, borderRadius).not.toBe("$10");
725
+ expect(featureSurface.padding, borderRadius).toBe(spacingRoles.featurePadding);
594
726
  }
595
727
  });
596
728
 
597
- it("featureSurface radius follows the radius knob, one token per stop around $10, uncapped by space", () => {
729
+ it("featureSurface radius follows the radius knob, one token per stop around $10", () => {
598
730
  const expected = {
599
731
  none: "$0",
600
732
  small: "$9",
@@ -603,46 +735,20 @@ describe("resolveKnobs", () => {
603
735
  full: "$12",
604
736
  } as const;
605
737
  for (const [borderRadius, token] of Object.entries(expected)) {
606
- for (const space of ["small", "medium", "large"] as const) {
607
- expect(
608
- resolveKnobs(knobsWith({ borderRadius: borderRadius as Knobs["borderRadius"], space }))
609
- .knobProps.featureSurface.borderRadius,
610
- `${borderRadius}/${space}`,
611
- ).toBe(token);
612
- }
613
- }
614
- });
615
-
616
- it("featureSurface padding follows the space knob, one token per stop around $6", () => {
617
- const expected = { small: "$5", medium: "$6", large: "$7" } as const;
618
- for (const [space, token] of Object.entries(expected)) {
619
- for (const borderRadius of ["none", "small", "medium", "large", "full"] as const) {
620
- expect(
621
- resolveKnobs(knobsWith({ borderRadius, space: space as Knobs["space"] })).knobProps
622
- .featureSurface.padding,
623
- `${borderRadius}/${space}`,
624
- ).toBe(token);
625
- }
738
+ expect(
739
+ resolveKnobs(knobsWith({ borderRadius: borderRadius as Knobs["borderRadius"] })).knobProps
740
+ .featureSurface.borderRadius,
741
+ borderRadius,
742
+ ).toBe(token);
626
743
  }
627
744
  });
628
745
 
629
- it("compact density steps featureSurface padding through the space knob", () => {
630
- const { knobProps } = resolveKnobs(applyDensity(knobsWith({ density: "compact" })));
631
- expect(knobProps.featureSurface.padding).toBe("$5");
632
- expect(knobProps.featureSurface.borderRadius).toBe("$10");
633
- });
634
-
635
- it("featureSurface never clips a child at its inset: padding >= radius x (1 - 1/sqrt 2) at every stop pair", () => {
746
+ it("featureSurface never clips a child at its inset: padding >= radius x (1 - 1/sqrt 2) at every radius stop", () => {
636
747
  for (const borderRadius of ["none", "small", "medium", "large", "full"] as const) {
637
- for (const space of ["small", "medium", "large"] as const) {
638
- const { featureSurface } = resolveKnobs(knobsWith({ borderRadius, space })).knobProps;
639
- const radiusPx = (tokens.radius as any)[featureSurface.borderRadius.slice(1)]
640
- .val as number;
641
- const paddingPx = (tokens.space as any)[featureSurface.padding.slice(1)].val as number;
642
- expect(paddingPx, `${borderRadius}/${space}`).toBeGreaterThanOrEqual(
643
- radiusPx * (1 - Math.SQRT1_2),
644
- );
645
- }
748
+ const { featureSurface } = resolveKnobs(knobsWith({ borderRadius })).knobProps;
749
+ const radiusPx = (tokens.radius as any)[featureSurface.borderRadius.slice(1)].val as number;
750
+ const paddingPx = (tokens.space as any)[featureSurface.padding.slice(1)].val as number;
751
+ expect(paddingPx, borderRadius).toBeGreaterThanOrEqual(radiusPx * (1 - Math.SQRT1_2));
646
752
  }
647
753
  });
648
754
  });
@@ -650,78 +756,89 @@ describe("resolveKnobs", () => {
650
756
  // ── container radius cap (Axiom 1 CLIP) ──────────────────
651
757
 
652
758
  describe("containerRadius (Axiom 1 child-clip cap)", () => {
653
- it("default knobs are untouched (radius $4 = 9px fits medium padding 18px)", () => {
759
+ it("default containers use $6 = 14px inside the 16px panel padding", () => {
654
760
  const { knobProps } = resolveKnobs(defaultKnobs);
655
- expect(knobProps.containerRadius).toEqual({ borderRadius: "$4" });
656
- expect(knobProps.cardSurface.borderRadius).toBe("$4");
761
+ expect(knobProps.containerRadius).toEqual({ borderRadius: "$6" });
762
+ expect(knobProps.cardSurface.borderRadius).toBe("$6");
657
763
  });
658
764
 
659
- it("caps full radius at the padding px for every space", () => {
660
- expect(
661
- resolveKnobs(knobsWith({ borderRadius: "full", space: "small" })).knobProps.containerRadius
662
- .borderRadius,
663
- ).toBe(13);
765
+ it("caps full radius at the panelPadding px of the installed scale", () => {
664
766
  expect(
665
- resolveKnobs(knobsWith({ borderRadius: "full", space: "medium" })).knobProps.containerRadius
666
- .borderRadius,
667
- ).toBe(18);
767
+ resolveKnobs(knobsWith({ borderRadius: "full" })).knobProps.containerRadius.borderRadius,
768
+ ).toBe(16);
769
+ const dense = generateScale({ control: 36, type: 14 }).tokens;
668
770
  expect(
669
- resolveKnobs(knobsWith({ borderRadius: "full", space: "large" })).knobProps.containerRadius
670
- .borderRadius,
671
- ).toBe(32);
672
- });
673
-
674
- it("only bites at large/full: none/small/medium keep their token at every space", () => {
675
- for (const space of Object.values(Space)) {
676
- for (const [radius, token] of [
677
- ["none", "$0"],
678
- ["small", "$2"],
679
- ["medium", "$4"],
680
- ] as const) {
681
- const { knobProps } = resolveKnobs(knobsWith({ borderRadius: radius, space }));
682
- expect(knobProps.containerRadius.borderRadius).toBe(token);
683
- }
771
+ onScale(
772
+ 36,
773
+ 14,
774
+ () => resolveKnobs(knobsWith({ borderRadius: "full" })).knobProps.containerRadius,
775
+ ).borderRadius,
776
+ ).toBe(dense.space.$4);
777
+ });
778
+
779
+ it("caps large and full; smaller container corners keep their token", () => {
780
+ for (const [radius, token] of [
781
+ ["none", "$0"],
782
+ ["small", "$4"],
783
+ ["medium", "$6"],
784
+ ["large", 16],
785
+ ["full", 16],
786
+ ] as const) {
787
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius: radius }));
788
+ expect(knobProps.containerRadius.borderRadius, radius).toBe(token);
684
789
  }
685
790
  });
686
791
 
687
- it("large radius caps only under small space (16px > 13px)", () => {
688
- expect(
689
- resolveKnobs(knobsWith({ borderRadius: "large", space: "small" })).knobProps.containerRadius
690
- .borderRadius,
691
- ).toBe(13);
692
- expect(
693
- resolveKnobs(knobsWith({ borderRadius: "large", space: "medium" })).knobProps
694
- .containerRadius.borderRadius,
695
- ).toBe("$6");
696
- expect(
697
- resolveKnobs(knobsWith({ borderRadius: "large", space: "large" })).knobProps.containerRadius
698
- .borderRadius,
699
- ).toBe("$6");
700
- });
701
-
702
- it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", () => {
792
+ it("compares generated scale px, so it agrees with the scale's own tokens (drift guard)", () => {
703
793
  const radiusTokenByKnob = {
704
794
  none: "$0",
705
- small: "$2",
706
- medium: "$4",
707
- large: "$6",
795
+ small: "$4",
796
+ medium: "$6",
797
+ large: "$8",
708
798
  full: "$12",
709
799
  } as const;
710
- const paddingTokenByKnob = { small: "$3", medium: "$4", large: "$6" } as const;
711
- for (const [radiusKnob, radiusToken] of Object.entries(radiusTokenByKnob)) {
712
- for (const [spaceKnob, paddingToken] of Object.entries(paddingTokenByKnob)) {
713
- const radiusPx = (tokens.radius as any)[radiusToken.slice(1)].val as number;
714
- const paddingPx = (tokens.space as any)[paddingToken.slice(1)].val as number;
800
+ for (const [control, type] of [
801
+ [40, 15],
802
+ [44, 15],
803
+ [36, 14],
804
+ [48, 16],
805
+ ] as const) {
806
+ const scale = generateScale({ control, type }).tokens;
807
+ for (const [radiusKnob, radiusToken] of Object.entries(radiusTokenByKnob)) {
808
+ const radiusPx = scale.radius[radiusToken.slice(1) as "0"];
809
+ const paddingPx = scale.space.$4;
715
810
  const expected = radiusPx > paddingPx ? paddingPx : radiusToken;
716
811
  expect(
717
- capContainerRadius(radiusKnob as Knobs["borderRadius"], spaceKnob as Knobs["space"]),
718
- `radius=${radiusKnob} space=${spaceKnob}`,
812
+ onScale(control, type, () => capContainerRadius(radiusKnob as Knobs["borderRadius"])),
813
+ `radius=${radiusKnob} scale=${control}/${type}`,
719
814
  ).toBe(expected);
720
815
  }
721
816
  }
722
817
  });
723
818
  });
724
819
 
820
+ it("containers step above controls while TextArea keeps the capped Input corner at every stop", () => {
821
+ for (const [borderRadius, containerPx, fieldPx] of [
822
+ ["none", 0, 0],
823
+ ["small", 8, 4],
824
+ ["medium", 14, 8],
825
+ ["large", 16, 14],
826
+ ["full", 16, 16],
827
+ ] as const) {
828
+ const { knobProps } = resolveKnobs(knobsWith({ borderRadius }));
829
+ const px = (value: string | number) =>
830
+ typeof value === "number" ? value : defaultScaleTokens.radius[value.slice(1) as "0"];
831
+ const controlPx = px(knobProps.borderRadius.borderRadius);
832
+ expect(px(knobProps.containerRadius.borderRadius)).toBe(containerPx);
833
+ expect(px(knobProps.fieldRadius.borderRadius)).toBe(fieldPx);
834
+ expect(capFieldRadius(borderRadius)).toBe(knobProps.fieldRadius.borderRadius);
835
+ expect(fieldPx).toBe(Math.min(controlPx, panelPaddingPx()));
836
+ if (borderRadius === "none") expect(containerPx).toBe(controlPx);
837
+ else if (controlPx < panelPaddingPx()) expect(containerPx).toBeGreaterThan(controlPx);
838
+ else expect(containerPx).toBe(panelPaddingPx());
839
+ }
840
+ });
841
+
725
842
  // ── cornerSmoothing (Axiom 15 OPTICS) ─────────────────────
726
843
 
727
844
  describe("cornerSmoothing knob", () => {
@@ -793,13 +910,11 @@ describe("resolveKnobs", () => {
793
910
  });
794
911
 
795
912
  it("smooth keeps the Axiom 1 container cap (clamped radii keep their caps)", () => {
796
- const smooth = resolveKnobs(
797
- knobsWith({ cornerSmoothing: "smooth", borderRadius: "full", space: "small" }),
798
- );
799
- const round = resolveKnobs(knobsWith({ borderRadius: "full", space: "small" }));
800
- expect(smooth.knobProps.containerRadius.borderRadius).toBe(13);
801
- expect(round.knobProps.containerRadius.borderRadius).toBe(13);
802
- expect(smooth.knobProps.cardSurface.borderRadius).toBe(13);
913
+ const smooth = resolveKnobs(knobsWith({ cornerSmoothing: "smooth", borderRadius: "full" }));
914
+ const round = resolveKnobs(knobsWith({ borderRadius: "full" }));
915
+ expect(smooth.knobProps.containerRadius.borderRadius).toBe(16);
916
+ expect(round.knobProps.containerRadius.borderRadius).toBe(16);
917
+ expect(smooth.knobProps.cardSurface.borderRadius).toBe(16);
803
918
  expect(smooth.knobProps.containerRadius.className).toBe(cornerSmoothClassName);
804
919
  });
805
920
 
@@ -965,16 +1080,16 @@ describe("resolveKnobs", () => {
965
1080
  resolveKnobs(knobsWith({ headingFont: "sans-serif" })).knobProps.heading.fontFamily,
966
1081
  ).toBe("$heading");
967
1082
  expect(resolveKnobs(knobsWith({ headingFont: "serif" })).knobProps.heading.fontFamily).toBe(
968
- "$serif",
1083
+ "$headingSerif",
969
1084
  );
970
1085
  expect(resolveKnobs(knobsWith({ headingFont: "mono" })).knobProps.heading.fontFamily).toBe(
971
- "$mono",
1086
+ "$headingMono",
972
1087
  );
973
1088
  expect(resolveKnobs(knobsWith({ headingFont: "slab" })).knobProps.heading.fontFamily).toBe(
974
- "$slab",
1089
+ "$headingSlab",
975
1090
  );
976
1091
  expect(resolveKnobs(knobsWith({ headingFont: "pixel" })).knobProps.heading.fontFamily).toBe(
977
- "$pixel",
1092
+ "$headingPixel",
978
1093
  );
979
1094
  });
980
1095
 
@@ -993,8 +1108,12 @@ describe("resolveKnobs", () => {
993
1108
  });
994
1109
 
995
1110
  it("fontWeight maps correctly", () => {
996
- expect(resolveKnobs(knobsWith({ fontWeight: "regular" })).knobProps.heading.fontWeight).toBe(
997
- "400",
1111
+ expect(
1112
+ resolveKnobs(knobsWith({ fontWeight: "regular" })).knobProps.heading,
1113
+ ).not.toHaveProperty("fontWeight");
1114
+ expect(resolveKnobs(defaultKnobs).knobProps.label).not.toHaveProperty("fontWeight");
1115
+ expect(resolveKnobs(knobsWith({ fontWeight: "bold" })).knobProps.label.fontWeight).toBe(
1116
+ "700",
998
1117
  );
999
1118
  expect(resolveKnobs(knobsWith({ fontWeight: "bold" })).knobProps.heading.fontWeight).toBe(
1000
1119
  "700",
@@ -1005,31 +1124,31 @@ describe("resolveKnobs", () => {
1005
1124
  // ── pageTitleScale (D-11 hero-H1 dial) ──────────────────────
1006
1125
 
1007
1126
  describe("pageTitleScale knob", () => {
1008
- it("defaults to the moderate page-title step ($8 = 32px)", () => {
1009
- expect(resolveKnobs(defaultKnobs).knobProps.pageTitle).toEqual({ size: "$8" });
1127
+ it("defaults to the moderate page-title step ($10 = 40px)", () => {
1128
+ expect(resolveKnobs(defaultKnobs).knobProps.pageTitle).toEqual({ size: "$10" });
1010
1129
  });
1011
1130
 
1012
- it('"display" re-opens the hero step ($10 = 64px)', () => {
1131
+ it('"display" re-opens the hero step ($12 = 52px)', () => {
1013
1132
  expect(resolveKnobs(knobsWith({ pageTitleScale: "display" })).knobProps.pageTitle).toEqual({
1014
- size: "$10",
1133
+ size: "$12",
1015
1134
  });
1016
1135
  });
1017
1136
 
1018
1137
  it("an omitted knob still resolves moderate (preset constructions predate the dial)", () => {
1019
1138
  const { pageTitleScale: _omitted, ...withoutDial } = defaultKnobs;
1020
- expect(resolveKnobs(withoutDial as Knobs).knobProps.pageTitle).toEqual({ size: "$8" });
1139
+ expect(resolveKnobs(withoutDial as Knobs).knobProps.pageTitle).toEqual({ size: "$10" });
1021
1140
  });
1022
1141
 
1023
1142
  it("resolvePageTitleScale is the one table both channels read", () => {
1024
- expect(resolvePageTitleScale("moderate")).toEqual({ size: "$8" });
1025
- expect(resolvePageTitleScale("display")).toEqual({ size: "$10" });
1143
+ expect(resolvePageTitleScale("moderate")).toEqual({ size: "$10" });
1144
+ expect(resolvePageTitleScale("display")).toEqual({ size: "$12" });
1026
1145
  });
1027
1146
 
1028
1147
  it("product presets ship moderate; `hero` is the only display opt-in", () => {
1029
1148
  expect(defaultPreset.knobs.pageTitleScale).toBe("moderate");
1030
1149
  expect(boldPreset.knobs.pageTitleScale).toBe("moderate");
1031
1150
  expect(heroPreset.knobs.pageTitleScale).toBe("display");
1032
- expect(resolveKnobs(heroPreset.knobs).knobProps.pageTitle).toEqual({ size: "$10" });
1151
+ expect(resolveKnobs(heroPreset.knobs).knobProps.pageTitle).toEqual({ size: "$12" });
1033
1152
  });
1034
1153
 
1035
1154
  it("`hero` differs from the default preset in nothing but the title step", () => {
@@ -1043,6 +1162,19 @@ describe("resolveKnobs", () => {
1043
1162
  // ── animation ─────────────────────────────────────────────
1044
1163
 
1045
1164
  describe("animation knob", () => {
1165
+ it("resolves each role while keeping the deprecated control alias", () => {
1166
+ const { knobProps } = resolveKnobs(knobsWith({ animation: "quick" }));
1167
+ expect(knobProps.motion.control.transition).toBe("quick");
1168
+ expect(knobProps.motion.overlay.transition).toBe("quicker");
1169
+ expect(knobProps.motion.scrim.transition).toBe("slow");
1170
+ expect(knobProps.motion.reveal.transition).toBe("medium");
1171
+ expect(knobProps.motion.slide.transition).toBe("medium");
1172
+ expect(knobProps.transition).toBe(knobProps.motion.control.transition);
1173
+ for (const role of Object.values(
1174
+ resolveKnobs(knobsWith({ animation: "none" })).knobProps.motion,
1175
+ ))
1176
+ expect(role.transition).toBeUndefined();
1177
+ });
1046
1178
  it('"none" → transition is undefined', () => {
1047
1179
  const { knobProps } = resolveKnobs(knobsWith({ animation: "none" }));
1048
1180
  expect(knobProps.transition).toBeUndefined();
@@ -1078,251 +1210,26 @@ describe("resolveKnobs", () => {
1078
1210
  expect(overridden.knobProps.elevation).toBe("$4");
1079
1211
  });
1080
1212
 
1081
- // T4.1g — override callback receives knobProps with both size and space
1082
- it("override callback receives knobProps with both size and space", () => {
1213
+ it("override callback receives the resolved roles and size token", () => {
1083
1214
  let receivedProps: KnobProps | undefined;
1084
- resolveKnobs(knobsWith({ size: "large", space: "small" }), (props) => {
1085
- receivedProps = props;
1086
- return {};
1087
- });
1088
- expect(receivedProps).toBeDefined();
1089
- expect(receivedProps!.size).toBe("large");
1090
- expect(receivedProps!.space).toBe("small");
1091
- });
1092
- });
1093
-
1094
- // ── Group 4: Gap review tests ─────────────────────────────
1095
-
1096
- describe("gap review", () => {
1097
- const sizeTokenMap: Record<string, string> = {
1098
- small: "$3",
1099
- medium: "$4",
1100
- large: "$5",
1101
- };
1102
-
1103
- const gapMap: Record<string, string> = {
1104
- small: "$2",
1105
- medium: "$4",
1106
- large: "$5",
1107
- };
1108
-
1109
- const panelPaddingMap: Record<string, string> = {
1110
- small: "$3",
1111
- medium: "$4",
1112
- large: "$6",
1113
- };
1114
-
1115
- const gapLgMap: Record<string, string> = {
1116
- small: "$3",
1117
- medium: "$5",
1118
- large: "$6",
1119
- };
1120
-
1121
- // T4.1a — every Size enum value produces a valid sizeToken
1122
- it("every Size enum value produces a valid sizeToken", () => {
1123
- for (const sizeVal of Object.values(Size)) {
1124
- const { knobProps } = resolveKnobs(knobsWith({ size: sizeVal }));
1125
- expect(knobProps.sizeToken).toBe(sizeTokenMap[sizeVal]);
1126
- }
1127
- });
1128
-
1129
- // T4.1b — every Space enum value produces valid panelPadding, gap, gapLg
1130
- it("every Space enum value produces valid panelPadding, gap, gapLg", () => {
1131
- for (const spaceVal of Object.values(Space)) {
1132
- const { knobProps } = resolveKnobs(knobsWith({ space: spaceVal }));
1133
- expect(knobProps.panelPadding.padding).toBe(panelPaddingMap[spaceVal]);
1134
- expect(knobProps.gap.gap).toBe(gapMap[spaceVal]);
1135
- expect(knobProps.gapLg.gap).toBe(gapLgMap[spaceVal]);
1136
- }
1137
- });
1138
-
1139
- // T4.1c — all 9 combinations of size × space
1140
- it("all 9 size × space combinations produce expected sizeToken and gap pairs", () => {
1141
- for (const sizeVal of Object.values(Size)) {
1142
- for (const spaceVal of Object.values(Space)) {
1143
- const { knobProps } = resolveKnobs(knobsWith({ size: sizeVal, space: spaceVal }));
1144
- expect(knobProps.sizeToken).toBe(sizeTokenMap[sizeVal]);
1145
- expect(knobProps.gap.gap).toBe(gapMap[spaceVal]);
1146
- expect(knobProps.panelPadding.padding).toBe(panelPaddingMap[spaceVal]);
1147
- expect(knobProps.gapLg.gap).toBe(gapLgMap[spaceVal]);
1148
- }
1149
- }
1150
- });
1151
-
1152
- // T4.1e — knobProps.space still equals knobs.space
1153
- it("knobProps.space equals knobs.space", () => {
1154
- for (const spaceVal of Object.values(Space)) {
1155
- const { knobProps } = resolveKnobs(knobsWith({ space: spaceVal }));
1156
- expect(knobProps.space).toBe(spaceVal);
1157
- }
1158
- });
1159
-
1160
- // T4.1f — knobProps.size equals knobs.size
1161
- it("knobProps.size equals knobs.size", () => {
1162
- for (const sizeVal of Object.values(Size)) {
1163
- const { knobProps } = resolveKnobs(knobsWith({ size: sizeVal }));
1164
- expect(knobProps.size).toBe(sizeVal);
1165
- }
1166
- });
1167
-
1168
- // T4.1h — defaultKnobs has both size and space set to "medium"
1169
- it('defaultKnobs has both size and space set to "medium"', () => {
1170
- expect(defaultKnobs.size).toBe("medium");
1171
- expect(defaultKnobs.space).toBe("medium");
1172
- });
1173
-
1174
- // T4.1i — emptyStateKnobs is still valid (W12: default hover layer remains)
1175
- it("emptyStateKnobs is still valid (no required size field breaks it)", () => {
1176
- const knobs = knobsWith({ hover: emptyStateKnobs });
1177
- const { control } = resolveKnobs(knobs);
1178
- expect(control.hoverKnobProps).toEqual({ backgroundColor: "$backgroundHover" });
1179
- });
1180
-
1181
- // T4.1j — size "large" does not change borderRadius
1182
- it('size "large" does not change borderRadius', () => {
1183
- const baseline = resolveKnobs(knobsWith({ size: "medium" }));
1184
- const large = resolveKnobs(knobsWith({ size: "large" }));
1185
- expect(large.knobProps.borderRadius).toEqual(baseline.knobProps.borderRadius);
1215
+ resolveKnobs(
1216
+ defaultKnobs,
1217
+ (props) => {
1218
+ receivedProps = props;
1219
+ return {};
1220
+ },
1221
+ "light",
1222
+ { sizeToken: "$3" },
1223
+ );
1224
+ expect(receivedProps!.sizeToken).toBe("$3");
1225
+ expect(receivedProps!.gap).toEqual({ gap: "$3" });
1186
1226
  });
1187
1227
  });
1188
- });
1189
-
1190
- // ── Density (DG-DEN-01 / W8) ─────────────────────────────────
1191
-
1192
- describe("applyDensity", () => {
1193
- it('defaultKnobs.density is "comfortable"', () => {
1194
- expect(defaultKnobs.density).toBe(Density.Comfortable);
1195
- });
1196
1228
 
1197
- it("comfortable density leaves size and space unchanged", () => {
1198
- const knobs = knobsWith({ size: "large", space: "large", density: "comfortable" });
1199
- const applied = applyDensity(knobs);
1200
- expect(applied.size).toBe("large");
1201
- expect(applied.space).toBe("large");
1202
- expect(applied.density).toBe("comfortable");
1203
- });
1204
-
1205
- it("compact density steps space down one level and leaves size unchanged", () => {
1206
- expect(
1207
- applyDensity(knobsWith({ size: "large", space: "large", density: "compact" })),
1208
- ).toMatchObject({ size: "large", space: "medium", density: "compact" });
1209
- expect(
1210
- applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
1211
- ).toMatchObject({ size: "medium", space: "small", density: "compact" });
1212
- expect(
1213
- applyDensity(knobsWith({ size: "small", space: "small", density: "compact" })),
1214
- ).toMatchObject({ size: "small", space: "small", density: "compact" });
1215
- });
1216
-
1217
- it("compact:true override wins over comfortable density knob without changing size", () => {
1218
- const applied = applyDensity(
1219
- knobsWith({ size: "medium", space: "medium", density: "comfortable" }),
1220
- true,
1221
- );
1222
- expect(applied).toMatchObject({ size: "medium", space: "small", density: "compact" });
1223
- });
1224
-
1225
- it("compact:false override wins over compact density knob", () => {
1226
- const applied = applyDensity(
1227
- knobsWith({ size: "medium", space: "medium", density: "compact" }),
1228
- false,
1229
- );
1230
- expect(applied).toMatchObject({ size: "medium", space: "medium", density: "comfortable" });
1231
- });
1232
-
1233
- it("resolveKnobs exposes density on knobProps", () => {
1234
- expect(resolveKnobs(defaultKnobs).knobProps.density).toBe("comfortable");
1235
- expect(resolveKnobs(knobsWith({ density: "compact" })).knobProps.density).toBe("compact");
1236
- });
1237
-
1238
- it("compact and comfortable differ in padX while height stays 44 (LC-76)", () => {
1239
- const comfortable = resolveKnobs(knobsWith({ density: "comfortable" }));
1240
- const compact = resolveKnobs(knobsWith({ density: "compact" }));
1241
- expect(comfortable.knobProps.control.height).toBe(44);
1242
- expect(compact.knobProps.control.height).toBe(44);
1243
- expect(comfortable.knobProps.sizeToken).toBe("$4");
1244
- expect(compact.knobProps.sizeToken).toBe("$4");
1245
- expect(compact.knobProps.control.paddingHorizontal).not.toBe(
1246
- comfortable.knobProps.control.paddingHorizontal,
1247
- );
1248
- expect(comfortable.knobProps.control.paddingHorizontal).toBe(18);
1249
- expect(compact.knobProps.control.paddingHorizontal).toBe(16);
1250
- });
1251
-
1252
- it("names which gap density moves: the LAYOUT gap, never the recipe gap (LC-79)", () => {
1253
- // `control.gap` retains the size-table grouping across density and shape.
1254
- // The gap a reader sees tighten is `knobProps.gap`, the
1255
- // space-ramp one, and it only moves once `applyDensity` has stepped
1256
- // `space` — `resolveKnobs` alone is a pure token mapper.
1257
- const comfortable = resolveKnobs(applyDensity(knobsWith({ space: "medium" })));
1258
- const compact = resolveKnobs(applyDensity(knobsWith({ space: "medium", density: "compact" })));
1259
- expect(compact.knobProps.control.gap).toBe(comfortable.knobProps.control.gap);
1260
- expect(compact.knobProps.gap).not.toEqual(comfortable.knobProps.gap);
1261
- expect(compact.knobProps.panelPadding).not.toEqual(comfortable.knobProps.panelPadding);
1262
- expect(compact.knobProps.control.height).toBe(44);
1263
- expect(comfortable.knobProps.control.height).toBe(44);
1264
- });
1265
-
1266
- it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
1267
- const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
1268
- const compact = resolveKnobs(
1269
- applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
1270
- );
1271
- expect(comfortable.knobProps.sizeToken).toBe("$4");
1272
- expect(compact.knobProps.sizeToken).toBe("$4");
1273
- expect(compact.knobProps.control.height).toBe(comfortable.knobProps.control.height);
1274
- expect(comfortable.knobProps.gap).toEqual({ gap: "$4" });
1275
- expect(compact.knobProps.gap).toEqual({ gap: "$2" });
1276
- });
1277
-
1278
- it("pairs label type to size — never the control-height sizeToken", () => {
1279
- expect(resolveKnobs(knobsWith({ size: "small" })).knobProps.label.fontSize).toBe("$2");
1280
- expect(resolveKnobs(knobsWith({ size: "medium" })).knobProps.label.fontSize).toBe("$3");
1281
- expect(resolveKnobs(knobsWith({ size: "large" })).knobProps.label.fontSize).toBe("$4");
1282
- expect(resolveKnobs(defaultKnobs).knobProps.label.fontSize).not.toBe(
1283
- resolveKnobs(defaultKnobs).knobProps.sizeToken,
1284
- );
1285
- });
1286
-
1287
- it("nestedControl paints below the 44px floor and restores press with hitSlop", () => {
1288
- const medium = resolveKnobs(knobsWith({ size: "medium" })).knobProps.nestedControl;
1289
- expect(medium.px).toBe(32);
1290
- expect(medium.width).toBe(32);
1291
- expect(medium.maxWidth).toBe(32);
1292
- expect(medium.hitSlop).toEqual({ top: 6, bottom: 6, left: 6, right: 6 });
1293
-
1294
- const compact = resolveKnobs(applyDensity(knobsWith({ size: "medium", density: "compact" })))
1295
- .knobProps.nestedControl;
1296
- // Compact steps space only (LC-76) — nestedControl follows size, so
1297
- // medium+compact stays on the medium nested box.
1298
- expect(compact.px).toBe(32);
1299
- expect(compact.hitSlop).toEqual({ top: 6, bottom: 6, left: 6, right: 6 });
1300
- });
1301
-
1302
- it("compact density preserves house-decision knobs", () => {
1303
- const knobs = knobsWith({
1304
- density: "compact",
1305
- fieldLabelPlacement: "side",
1306
- requiredMarking: "asterisk",
1307
- tableZebra: "on",
1308
- bulkBarPlacement: "bottom",
1309
- selectAllScope: "filtered",
1310
- timestampStyle: "relative",
1311
- disabledStyle: "dimWhole",
1312
- formAutofocus: "on",
1313
- });
1314
- const applied = applyDensity(knobs);
1315
- expect(applied).toMatchObject({
1316
- density: "compact",
1317
- fieldLabelPlacement: "side",
1318
- requiredMarking: "asterisk",
1319
- tableZebra: "on",
1320
- bulkBarPlacement: "bottom",
1321
- selectAllScope: "filtered",
1322
- timestampStyle: "relative",
1323
- disabledStyle: "dimWhole",
1324
- formAutofocus: "on",
1325
- });
1229
+ it("emptyStateKnobs is still valid (W12: default hover layer remains)", () => {
1230
+ const knobs = knobsWith({ hover: emptyStateKnobs });
1231
+ const { control } = resolveKnobs(knobs);
1232
+ expect(control.hoverKnobProps).toEqual({ backgroundColor: "$backgroundHover" });
1326
1233
  });
1327
1234
  });
1328
1235
 
@@ -1387,7 +1294,7 @@ describe("house decision knobs", () => {
1387
1294
  expect(housey.knobProps.sizeToken).toBe(baseline.knobProps.sizeToken);
1388
1295
  expect(housey.knobProps.gap).toEqual(baseline.knobProps.gap);
1389
1296
  expect(housey.knobProps.borderRadius).toEqual(baseline.knobProps.borderRadius);
1390
- expect(housey.knobProps.density).toBe(baseline.knobProps.density);
1297
+ expect(housey.knobProps.panelPadding).toEqual(baseline.knobProps.panelPadding);
1391
1298
  });
1392
1299
  });
1393
1300
 
@@ -1481,11 +1388,11 @@ describe("LC-67 touch height floor via resolveKnobs", () => {
1481
1388
  }));
1482
1389
  const { resolveKnobs: resolveTouch } = await import("./resolveKnobs");
1483
1390
  try {
1484
- const small = resolveTouch(knobsWith({ size: "small" }));
1485
- const medium = resolveTouch(knobsWith({ size: "medium" }));
1391
+ const small = resolveTouch(defaultKnobs, undefined, "light", { sizeToken: "$3" });
1392
+ const medium = resolveTouch(defaultKnobs);
1486
1393
  expect(small.knobProps.control.height).toBeGreaterThanOrEqual(44);
1487
1394
  expect(medium.knobProps.control.height).toBeGreaterThanOrEqual(44);
1488
- expect(medium.knobProps.control.height).toBeGreaterThan(small.knobProps.control.height);
1395
+ expect(medium.knobProps.control.height).toBe(small.knobProps.control.height);
1489
1396
  } finally {
1490
1397
  vi.doUnmock("@multiplatform.one/platform");
1491
1398
  vi.resetModules();
@@ -1495,16 +1402,49 @@ describe("LC-67 touch height floor via resolveKnobs", () => {
1495
1402
 
1496
1403
  describe("containerCapProps (DG-RAD-04 provenance)", () => {
1497
1404
  it("names the knob stop a borderRadiusMap token came from", () => {
1498
- expect(containerCapProps("PageSection", "$12", "small")).toEqual({
1405
+ expect(containerCapProps("PageSection", "$12")).toEqual({
1499
1406
  "data-constraint-container": "PageSection",
1500
1407
  "data-radius-knob": "full",
1501
- "data-space-knob": "small",
1502
1408
  });
1503
1409
  });
1504
1410
 
1411
+ it("decodes control tokens even when a container uses a larger key", () => {
1412
+ expect(containerCapProps("PageSection", "$4")?.["data-radius-knob"]).toBe("medium");
1413
+ expect(containerCapProps("TextArea", "$6")?.["data-radius-knob"]).toBe("large");
1414
+ expect(containerCapProps("PageSection", "$8")).toBeUndefined();
1415
+ });
1416
+
1505
1417
  it("makes no claim for a radius that did not come from the knob", () => {
1506
- expect(containerCapProps("TextArea", 13, "small")).toBeUndefined();
1507
- expect(containerCapProps("TextArea", "$5", "small")).toBeUndefined();
1508
- expect(containerCapProps("TextArea", "$12", "roomy")).toBeUndefined();
1418
+ expect(containerCapProps("TextArea", 13)).toBeUndefined();
1419
+ expect(containerCapProps("TextArea", "$5")).toBeUndefined();
1420
+ });
1421
+ });
1422
+
1423
+ describe("painted ancestor (WP-c2: nested surfaces stop re-elevating)", () => {
1424
+ const knobs = { ...defaultKnobs, elevation: "large" as const };
1425
+
1426
+ it("top level: elevatedSurface carries the overlay elevation and paint", () => {
1427
+ const { knobProps } = resolveKnobs(knobs);
1428
+ expect(knobProps.elevatedSurface.elevation).toBe(knobProps.overlayElevation);
1429
+ expect(knobProps.elevatedSurface.shadowRadius).toBeGreaterThan(0);
1430
+ expect(knobProps.cardSurface.backgroundColor).toBe("$background");
1431
+ });
1432
+
1433
+ it("under a painted ancestor: elevatedSurface is border only, cardSurface one step off", () => {
1434
+ const { knobProps } = resolveKnobs(knobs, undefined, "light", { painted: true });
1435
+ expect(knobProps.elevatedSurface.elevation).toBeUndefined();
1436
+ expect(knobProps.elevatedSurface.shadowRadius).toBeUndefined();
1437
+ expect(knobProps.elevatedSurface.style).toBeUndefined();
1438
+ expect(knobProps.elevatedSurface.borderWidth).toBeGreaterThan(0);
1439
+ expect(knobProps.elevatedSurface.borderColor).toBe("$borderColor");
1440
+ expect(knobProps.cardSurface.backgroundColor).toBe(paintedCardFill);
1441
+ expect(paintedCardFill).toBe("$color2");
1442
+ });
1443
+
1444
+ it("border only survives borderWidth none: the hairline floor still draws the box", () => {
1445
+ const { knobProps } = resolveKnobs({ ...knobs, borderWidth: "none" }, undefined, "light", {
1446
+ painted: true,
1447
+ });
1448
+ expect(knobProps.elevatedSurface.borderWidth).toBeGreaterThan(0);
1509
1449
  });
1510
1450
  });