@multiplatform.one/theme 6.6.0 → 7.0.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 (71) hide show
  1. package/package.json +18 -18
  2. package/src/dates.spec.ts +344 -1
  3. package/src/dates.ts +50 -3
  4. package/src/devWarn.ts +29 -1
  5. package/src/figma/figmaTokens.spec.ts +3 -3
  6. package/src/figma/figmaTokens.ts +6 -3
  7. package/src/keyboardFocusRing.ts +61 -5
  8. package/src/menuRow.spec.ts +2 -2
  9. package/src/menuRow.ts +5 -4
  10. package/src/theme/Surface.spec.tsx +146 -0
  11. package/src/theme/Surface.tsx +73 -0
  12. package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
  13. package/src/theme/createDefaultThemeConfig.ts +47 -3
  14. package/src/theme/createThemes.ts +27 -4
  15. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
  16. package/src/theme/focusState.ts +9 -1
  17. package/src/theme/groupGeometry.spec.ts +108 -0
  18. package/src/theme/groupGeometry.ts +119 -0
  19. package/src/theme/index.ts +3 -0
  20. package/src/theme/intent.spec.tsx +17 -0
  21. package/src/theme/knobs.ts +2 -2
  22. package/src/theme/layoutTokens.spec.ts +14 -0
  23. package/src/theme/layoutTokens.ts +29 -0
  24. package/src/theme/recipeInputs.ts +368 -0
  25. package/src/theme/recipes.ts +34 -0
  26. package/src/theme/resolveKnobs.spec.ts +91 -9
  27. package/src/theme/resolveKnobs.ts +55 -5
  28. package/src/theme/shared.tsx +11 -0
  29. package/src/theme/sizeRecipes.spec.ts +296 -0
  30. package/src/theme/sizeRecipes.ts +198 -0
  31. package/src/theme/useResolvedKnobs.ts +76 -24
  32. package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
  33. package/types/dates.d.ts +36 -2
  34. package/types/dates.d.ts.map +1 -1
  35. package/types/devWarn.d.ts +16 -1
  36. package/types/devWarn.d.ts.map +1 -1
  37. package/types/figma/figmaTokens.d.ts +1 -1
  38. package/types/figma/figmaTokens.d.ts.map +1 -1
  39. package/types/keyboardFocusRing.d.ts +19 -4
  40. package/types/keyboardFocusRing.d.ts.map +1 -1
  41. package/types/menuRow.d.ts +5 -4
  42. package/types/menuRow.d.ts.map +1 -1
  43. package/types/theme/Surface.d.ts +37 -0
  44. package/types/theme/Surface.d.ts.map +1 -0
  45. package/types/theme/createDefaultThemeConfig.d.ts +14 -3
  46. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  47. package/types/theme/createThemes.d.ts.map +1 -1
  48. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  49. package/types/theme/focusState.d.ts +8 -0
  50. package/types/theme/focusState.d.ts.map +1 -1
  51. package/types/theme/groupGeometry.d.ts +69 -0
  52. package/types/theme/groupGeometry.d.ts.map +1 -0
  53. package/types/theme/index.d.ts +3 -0
  54. package/types/theme/index.d.ts.map +1 -1
  55. package/types/theme/knobs.d.ts +2 -2
  56. package/types/theme/knobs.d.ts.map +1 -1
  57. package/types/theme/layoutTokens.d.ts +22 -0
  58. package/types/theme/layoutTokens.d.ts.map +1 -1
  59. package/types/theme/recipeInputs.d.ts +110 -0
  60. package/types/theme/recipeInputs.d.ts.map +1 -0
  61. package/types/theme/recipes.d.ts +47 -0
  62. package/types/theme/recipes.d.ts.map +1 -1
  63. package/types/theme/resolveKnobs.d.ts +7 -3
  64. package/types/theme/resolveKnobs.d.ts.map +1 -1
  65. package/types/theme/shared.d.ts +9 -0
  66. package/types/theme/shared.d.ts.map +1 -1
  67. package/types/theme/sizeRecipes.d.ts +68 -0
  68. package/types/theme/sizeRecipes.d.ts.map +1 -0
  69. package/types/theme/useResolvedKnobs.d.ts +10 -6
  70. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  71. package/src/audit/baselines/home-pokemon.json +0 -355
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Size-recipe INPUTS (LC-70 RECIPE-INPUTS).
3
+ *
4
+ * Stored numbers are the source of truth. Tables (heights, recipes,
5
+ * box/type/icon variants, touch heights) are DERIVED — never handwritten.
6
+ * Radius is stored on inputs (`radiusPx`) and MUST NOT appear on recipes
7
+ * or variants; it stays on the borderRadius knob.
8
+ *
9
+ * Tamagui `createThemes` / theme-builder still emits COLOR palettes only.
10
+ * Do not stuff recipe keys into `getTheme()` / `themes`.
11
+ *
12
+ * DEFAULT uses `tokenHeights` so published px match today's lossless table
13
+ * ($4=32, $3=28, $5=40, $6=48). `{ baseHeight: 40 }` (no tokenHeights /
14
+ * sizeFactors) regenerates geometrically from `baseHeight * stepRatio ** step`.
15
+ */
16
+
17
+ export const SIZE_RECIPE_TOKENS = [
18
+ "$1",
19
+ "$1.5",
20
+ "$2",
21
+ "$2.5",
22
+ "$3",
23
+ "$3.5",
24
+ "$4",
25
+ "$true",
26
+ "$5",
27
+ "$6",
28
+ ] as const;
29
+
30
+ export type SizeRecipeToken = (typeof SIZE_RECIPE_TOKENS)[number];
31
+
32
+ export type SizeRecipeFamilyName = "control" | "controlCompact";
33
+ /** @deprecated Use `SizeRecipeFamilyName`. */
34
+ export type RecipeFamilyName = SizeRecipeFamilyName;
35
+
36
+ export type SizeRecipeRatios = {
37
+ padX: number;
38
+ font: number;
39
+ icon: number;
40
+ gap: number;
41
+ };
42
+
43
+ export type SizeRecipeInputs = {
44
+ baseHeight: number;
45
+ stepRatio: number;
46
+ tokenSteps: Record<string, number>;
47
+ /** Absolute desktop px. When omitted, heights come from baseHeight * stepRatio ** step (or sizeFactors). */
48
+ tokenHeights?: Record<string, number>;
49
+ /**
50
+ * Optional per-token scale relative to `baseHeight`. Used only when
51
+ * `tokenHeights` is omitted. Factors are `stepRatio ** exponent` otherwise.
52
+ */
53
+ sizeFactors?: Partial<Record<SizeRecipeToken, number>>;
54
+ ratios: SizeRecipeRatios;
55
+ compactRatios: SizeRecipeRatios;
56
+ radiusPx: number;
57
+ touchFloor: number;
58
+ };
59
+
60
+ /** @deprecated Use `SizeRecipeInputs`. */
61
+ export type RecipeInputs = SizeRecipeInputs;
62
+
63
+ export type SizeRecipe = {
64
+ height: number;
65
+ paddingHorizontal: number;
66
+ fontSize: number;
67
+ iconSize: number;
68
+ gap: number;
69
+ };
70
+
71
+ export type SizeRecipeBoxVariant = {
72
+ height: number;
73
+ paddingHorizontal: number;
74
+ gap: number;
75
+ };
76
+
77
+ export type SizeRecipeTypeVariant = { fontSize: number };
78
+ export type SizeRecipeIconVariant = { width: number; height: number };
79
+
80
+ export type SizeRecipeTables = {
81
+ inputs: SizeRecipeInputs;
82
+ ratios: SizeRecipeRatios;
83
+ heights: Record<string, number>;
84
+ touchHeights: Record<string, number>;
85
+ recipes: Record<string, SizeRecipe>;
86
+ recipesTouch: Record<string, SizeRecipe>;
87
+ boxVariants: Record<string, SizeRecipeBoxVariant>;
88
+ typeVariants: Record<string, SizeRecipeTypeVariant>;
89
+ iconVariants: Record<string, SizeRecipeIconVariant>;
90
+ heightVariants: Record<string, { height: number }>;
91
+ };
92
+
93
+ export type SizeRecipeFamily = SizeRecipeTables & { name: SizeRecipeFamilyName };
94
+
95
+ export type RecipeFamilies = Record<SizeRecipeFamilyName, SizeRecipeFamily>;
96
+
97
+ export type GeneratedSizeRecipes = {
98
+ inputs: SizeRecipeInputs;
99
+ heights: Record<string, number>;
100
+ heightsTouch: Record<string, number>;
101
+ recipes: Record<string, SizeRecipe>;
102
+ recipesTouch: Record<string, SizeRecipe>;
103
+ boxVariants: Record<string, SizeRecipeBoxVariant>;
104
+ typeVariants: Record<string, SizeRecipeTypeVariant>;
105
+ iconVariants: Record<string, SizeRecipeIconVariant>;
106
+ families: RecipeFamilies;
107
+ };
108
+
109
+ export const SIZE_TOKEN_EXPONENTS: Record<SizeRecipeToken, number> = {
110
+ $1: -3,
111
+ "$1.5": -2.5,
112
+ $2: -2,
113
+ "$2.5": -1.5,
114
+ $3: -1,
115
+ "$3.5": -0.5,
116
+ $4: 0,
117
+ $true: 0,
118
+ $5: 1,
119
+ $6: 2,
120
+ };
121
+
122
+ const DEFAULT_TOKEN_HEIGHTS: Record<string, number> = {
123
+ $1: 20,
124
+ "$1.5": 24,
125
+ $2: 24,
126
+ "$2.5": 28,
127
+ $3: 28,
128
+ "$3.5": 30,
129
+ $4: 32,
130
+ $true: 32,
131
+ $5: 40,
132
+ $6: 48,
133
+ };
134
+
135
+ /** Lossless relative table: today's 20/24/28/32/40/48 ramp at baseHeight 32. */
136
+ export const defaultSizeFactors: Record<SizeRecipeToken, number> = {
137
+ $1: 20 / 32,
138
+ "$1.5": 24 / 32,
139
+ $2: 24 / 32,
140
+ "$2.5": 28 / 32,
141
+ $3: 28 / 32,
142
+ "$3.5": 30 / 32,
143
+ $4: 1,
144
+ $true: 1,
145
+ $5: 40 / 32,
146
+ $6: 48 / 32,
147
+ };
148
+
149
+ const DEFAULT_RATIOS: SizeRecipeRatios = {
150
+ padX: 12 / 32,
151
+ font: 14 / 32,
152
+ icon: 16 / 32,
153
+ gap: 6 / 32,
154
+ };
155
+
156
+ const DEFAULT_COMPACT_RATIOS: SizeRecipeRatios = {
157
+ padX: 8 / 32,
158
+ font: 13 / 32,
159
+ icon: 14 / 32,
160
+ gap: 4 / 32,
161
+ };
162
+
163
+ export const DEFAULT_SIZE_RECIPE_INPUTS: SizeRecipeInputs = {
164
+ baseHeight: 32,
165
+ stepRatio: 1.25,
166
+ tokenSteps: { ...SIZE_TOKEN_EXPONENTS },
167
+ tokenHeights: { ...DEFAULT_TOKEN_HEIGHTS },
168
+ sizeFactors: { ...defaultSizeFactors },
169
+ ratios: { ...DEFAULT_RATIOS },
170
+ compactRatios: { ...DEFAULT_COMPACT_RATIOS },
171
+ radiusPx: 6,
172
+ touchFloor: 44,
173
+ };
174
+
175
+ export const defaultRecipeInputs = DEFAULT_SIZE_RECIPE_INPUTS;
176
+
177
+ export const TOUCH_HEIGHT_FLOOR = DEFAULT_SIZE_RECIPE_INPUTS.touchFloor;
178
+
179
+ const MD_TOUCH_MIN = 48;
180
+
181
+ export function cloneSizeRecipeInputs(inputs: SizeRecipeInputs): SizeRecipeInputs {
182
+ return {
183
+ ...inputs,
184
+ tokenSteps: { ...inputs.tokenSteps },
185
+ tokenHeights: inputs.tokenHeights ? { ...inputs.tokenHeights } : undefined,
186
+ sizeFactors: inputs.sizeFactors ? { ...inputs.sizeFactors } : undefined,
187
+ ratios: { ...inputs.ratios },
188
+ compactRatios: { ...inputs.compactRatios },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Fill defaults. `{ baseHeight: 36 }` drops handwritten tokenHeights/sizeFactors
194
+ * so every token regenerates from stepRatio.
195
+ */
196
+ export function resolveSizeRecipeInputs(partial: Partial<SizeRecipeInputs> = {}): SizeRecipeInputs {
197
+ const scalingOverride =
198
+ partial.baseHeight != null || partial.stepRatio != null || partial.tokenSteps != null;
199
+ const hasTokenHeights = Object.prototype.hasOwnProperty.call(partial, "tokenHeights");
200
+ const hasSizeFactors = Object.prototype.hasOwnProperty.call(partial, "sizeFactors");
201
+
202
+ const resolved: SizeRecipeInputs = {
203
+ baseHeight: partial.baseHeight ?? DEFAULT_SIZE_RECIPE_INPUTS.baseHeight,
204
+ stepRatio: partial.stepRatio ?? DEFAULT_SIZE_RECIPE_INPUTS.stepRatio,
205
+ tokenSteps: { ...DEFAULT_SIZE_RECIPE_INPUTS.tokenSteps, ...partial.tokenSteps },
206
+ ratios: { ...DEFAULT_SIZE_RECIPE_INPUTS.ratios, ...partial.ratios },
207
+ compactRatios: { ...DEFAULT_SIZE_RECIPE_INPUTS.compactRatios, ...partial.compactRatios },
208
+ radiusPx: partial.radiusPx ?? DEFAULT_SIZE_RECIPE_INPUTS.radiusPx,
209
+ touchFloor: partial.touchFloor ?? DEFAULT_SIZE_RECIPE_INPUTS.touchFloor,
210
+ };
211
+
212
+ if (hasTokenHeights) {
213
+ resolved.tokenHeights = partial.tokenHeights ? { ...partial.tokenHeights } : undefined;
214
+ if (hasSizeFactors) {
215
+ resolved.sizeFactors = partial.sizeFactors ? { ...partial.sizeFactors } : undefined;
216
+ }
217
+ } else if (hasSizeFactors) {
218
+ resolved.sizeFactors = partial.sizeFactors ? { ...partial.sizeFactors } : undefined;
219
+ } else if (!scalingOverride) {
220
+ resolved.tokenHeights = { ...DEFAULT_SIZE_RECIPE_INPUTS.tokenHeights };
221
+ resolved.sizeFactors = { ...DEFAULT_SIZE_RECIPE_INPUTS.sizeFactors };
222
+ }
223
+
224
+ return resolved;
225
+ }
226
+
227
+ export const mergeRecipeInputs = resolveSizeRecipeInputs;
228
+
229
+ function clampHeight(value: number): number {
230
+ return Math.max(1, Math.round(value));
231
+ }
232
+
233
+ function heightForToken(token: string, inputs: SizeRecipeInputs): number {
234
+ const explicitPx = inputs.tokenHeights?.[token];
235
+ if (explicitPx != null) return clampHeight(explicitPx);
236
+ const factor = inputs.sizeFactors?.[token as SizeRecipeToken];
237
+ if (factor != null) return clampHeight(inputs.baseHeight * factor);
238
+ const step = inputs.tokenSteps[token] ?? 0;
239
+ return clampHeight(inputs.baseHeight * inputs.stepRatio ** step);
240
+ }
241
+
242
+ export function sizeRecipeFromHeight(
243
+ height: number,
244
+ ratios: SizeRecipeRatios = DEFAULT_SIZE_RECIPE_INPUTS.ratios,
245
+ ): SizeRecipe {
246
+ return {
247
+ height,
248
+ paddingHorizontal: Math.round(height * ratios.padX),
249
+ fontSize: Math.round(height * ratios.font),
250
+ iconSize: Math.round(height * ratios.icon),
251
+ gap: Math.round(height * ratios.gap),
252
+ };
253
+ }
254
+
255
+ function generateTouchHeights(
256
+ heights: Record<string, number>,
257
+ inputs: SizeRecipeInputs,
258
+ tokens: readonly string[],
259
+ ): Record<string, number> {
260
+ const { touchFloor, tokenSteps } = inputs;
261
+ const mdDesktop = heights.$4 ?? inputs.baseHeight;
262
+ const floored: Record<string, number> = {};
263
+ for (const token of tokens) {
264
+ floored[token] = Math.max(heights[token], touchFloor);
265
+ }
266
+
267
+ let mdTouch = Math.max(floored.$4 ?? touchFloor, MD_TOUCH_MIN);
268
+ const below = tokens.filter((token) => (tokenSteps[token] ?? 0) < 0);
269
+ const maxBelow =
270
+ below.length > 0 ? Math.max(...below.map((token) => floored[token])) : touchFloor;
271
+ if (mdTouch <= maxBelow) {
272
+ const nextSmaller = heights.$3 ?? mdDesktop;
273
+ mdTouch = maxBelow + Math.max(mdDesktop - nextSmaller, 1);
274
+ }
275
+
276
+ const heightsTouch: Record<string, number> = {};
277
+ for (const token of tokens) {
278
+ const step = tokenSteps[token] ?? 0;
279
+ if (step < 0) {
280
+ heightsTouch[token] = floored[token];
281
+ } else if (step === 0) {
282
+ heightsTouch[token] = mdTouch;
283
+ } else {
284
+ heightsTouch[token] = Math.max(floored[token], mdTouch + (heights[token] - mdDesktop));
285
+ }
286
+ }
287
+ return heightsTouch;
288
+ }
289
+
290
+ export function generateSizeRecipeTables(
291
+ inputs: SizeRecipeInputs,
292
+ ratios: SizeRecipeRatios = inputs.ratios,
293
+ ): SizeRecipeTables {
294
+ const tokens = SIZE_RECIPE_TOKENS;
295
+ const heights: Record<string, number> = {};
296
+ for (const token of tokens) {
297
+ heights[token] = heightForToken(token, inputs);
298
+ }
299
+ if (inputs.tokenHeights?.$true == null) {
300
+ heights.$true = heights.$4;
301
+ }
302
+
303
+ const touchHeights = generateTouchHeights(heights, inputs, tokens);
304
+ const recipes: Record<string, SizeRecipe> = {};
305
+ const recipesTouch: Record<string, SizeRecipe> = {};
306
+ const boxVariants: Record<string, SizeRecipeBoxVariant> = {};
307
+ const typeVariants: Record<string, SizeRecipeTypeVariant> = {};
308
+ const iconVariants: Record<string, SizeRecipeIconVariant> = {};
309
+ const heightVariants: Record<string, { height: number }> = {};
310
+
311
+ for (const token of tokens) {
312
+ const recipe = sizeRecipeFromHeight(heights[token], ratios);
313
+ recipes[token] = recipe;
314
+ recipesTouch[token] = sizeRecipeFromHeight(touchHeights[token], ratios);
315
+ boxVariants[token] = {
316
+ height: recipe.height,
317
+ paddingHorizontal: recipe.paddingHorizontal,
318
+ gap: recipe.gap,
319
+ };
320
+ typeVariants[token] = { fontSize: recipe.fontSize };
321
+ iconVariants[token] = { width: recipe.iconSize, height: recipe.iconSize };
322
+ heightVariants[token] = { height: recipe.height };
323
+ }
324
+
325
+ return {
326
+ inputs,
327
+ ratios: { ...ratios },
328
+ heights,
329
+ touchHeights,
330
+ recipes,
331
+ recipesTouch,
332
+ boxVariants,
333
+ typeVariants,
334
+ iconVariants,
335
+ heightVariants,
336
+ };
337
+ }
338
+
339
+ export function createRecipeFamilies(
340
+ inputs: SizeRecipeInputs | Partial<SizeRecipeInputs> = {},
341
+ ): RecipeFamilies {
342
+ const resolved = cloneSizeRecipeInputs(resolveSizeRecipeInputs(inputs));
343
+ const control = generateSizeRecipeTables(resolved, resolved.ratios);
344
+ // Same heights as control; tighter pad/font/icon/gap via compactRatios
345
+ // (padX 8/32, font 13/32, icon 14/32, gap 4/32). Not a 28px family.
346
+ const controlCompact = generateSizeRecipeTables(resolved, resolved.compactRatios);
347
+ return {
348
+ control: { ...control, name: "control" },
349
+ controlCompact: { ...controlCompact, name: "controlCompact" },
350
+ };
351
+ }
352
+
353
+ export function generateSizeRecipes(partial: Partial<SizeRecipeInputs> = {}): GeneratedSizeRecipes {
354
+ const inputs = cloneSizeRecipeInputs(resolveSizeRecipeInputs(partial));
355
+ const families = createRecipeFamilies(inputs);
356
+ const control = families.control;
357
+ return {
358
+ inputs,
359
+ heights: control.heights,
360
+ heightsTouch: control.touchHeights,
361
+ recipes: control.recipes,
362
+ recipesTouch: control.recipesTouch,
363
+ boxVariants: control.boxVariants,
364
+ typeVariants: control.typeVariants,
365
+ iconVariants: control.iconVariants,
366
+ families,
367
+ };
368
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ColorTokens, FontSizeTokens, TransitionProp } from "tamagui";
2
2
  import type { Knobs } from "./knobs";
3
+ import type { HitSlopInsets } from "./layoutTokens";
3
4
 
4
5
  // ── State knob prop types (by concern) ───────────────────────────────────
5
6
 
@@ -152,7 +153,40 @@ export interface KnobProps {
152
153
  panelPadding: { padding: string };
153
154
  gap: { gap: string };
154
155
  gapLg: { gap: string };
156
+ /**
157
+ * Size-recipe KEY (string "$4"), not a height scalar. styled() variants
158
+ * switch on this token; control internals read `control` / `controlType` /
159
+ * `controlIcon` (LC-65). Radius is not on the recipe.
160
+ */
155
161
  sizeToken: string;
162
+ /**
163
+ * Control-chrome type (labels, calendar digits, weekday headers). Paired
164
+ * to `size` so nested surfaces can step type with density. NEVER pass
165
+ * `sizeToken` as `fontSize` — that token is a control HEIGHT ($4 = 44px
166
+ * box), not a type step ($4 font = 14px).
167
+ */
168
+ label: { fontSize: FontSizeTokens; fontFamily: string; fontWeight: string };
169
+ /**
170
+ * Nested hit-target (calendar days, overlay chips): visual box BELOW the
171
+ * 44px floor, press restored with `hitSlop` (LC-54 / LC-71). Spread the
172
+ * box onto the painted cell; pass `hitSlop` to the press handler.
173
+ */
174
+ nestedControl: {
175
+ px: number;
176
+ width: number;
177
+ height: number;
178
+ minWidth: number;
179
+ maxWidth: number;
180
+ minHeight: number;
181
+ maxHeight: number;
182
+ hitSlop: HitSlopInsets;
183
+ };
184
+ /** Coordinated control box (height / padX / inner gap). Spread whole. */
185
+ control: { height: number; paddingHorizontal: number; gap: number };
186
+ /** Coordinated control type. Spread whole. */
187
+ controlType: { fontSize: number };
188
+ /** Coordinated control icon box. Spread whole. */
189
+ controlIcon: { width: number; height: number };
156
190
  heading: { fontFamily: string; fontWeight: string };
157
191
  /**
158
192
  * Page-title type step (D-11 hero-H1 dial) — the complete size fragment for
@@ -51,6 +51,11 @@ const expectedKnobPropKeys: (keyof KnobProps)[] = [
51
51
  "gap",
52
52
  "gapLg",
53
53
  "sizeToken",
54
+ "label",
55
+ "nestedControl",
56
+ "control",
57
+ "controlType",
58
+ "controlIcon",
54
59
  "heading",
55
60
  "pageTitle",
56
61
  "body",
@@ -234,7 +239,11 @@ describe("resolveKnobs", () => {
234
239
  describe("elevation knob (native)", () => {
235
240
  it("emits RN-safe numbers, never token strings", async () => {
236
241
  vi.resetModules();
237
- vi.doMock("@multiplatform.one/platform", () => ({ isWeb: false }));
242
+ vi.doMock("@multiplatform.one/platform", () => ({
243
+ isWeb: false,
244
+ isTouchable: false,
245
+ isWebTouchable: false,
246
+ }));
238
247
  const { resolveKnobs: resolveNative } = await import("./resolveKnobs");
239
248
  try {
240
249
  expect(resolveNative(knobsWith({ elevation: "none" })).knobProps.elevation).toBeUndefined();
@@ -385,6 +394,28 @@ describe("resolveKnobs", () => {
385
394
  expect(resolved.knobProps.panelPadding.padding).toBe("$4");
386
395
  expect(resolved.knobProps.gapLg.gap).toBe("$5");
387
396
  });
397
+
398
+ it("fills control / controlType / controlIcon from the size recipe", () => {
399
+ const { knobProps } = resolveKnobs(defaultKnobs);
400
+ expect(knobProps.control).toEqual({
401
+ height: 32,
402
+ paddingHorizontal: 12,
403
+ gap: 6,
404
+ });
405
+ expect(knobProps.controlType).toEqual({ fontSize: 14 });
406
+ expect(knobProps.controlIcon).toEqual({ width: 16, height: 16 });
407
+ expect(knobProps.control).not.toHaveProperty("borderRadius");
408
+ expect(knobProps.controlType).not.toHaveProperty("borderRadius");
409
+ expect(knobProps.controlIcon).not.toHaveProperty("borderRadius");
410
+ });
411
+
412
+ it("small and large share the same borderRadius fragment", () => {
413
+ const small = resolveKnobs(knobsWith({ size: "small" }));
414
+ const large = resolveKnobs(knobsWith({ size: "large" }));
415
+ expect(small.knobProps.control.height).toBe(28);
416
+ expect(large.knobProps.control.height).toBe(40);
417
+ expect(small.knobProps.borderRadius).toEqual(large.knobProps.borderRadius);
418
+ });
388
419
  });
389
420
 
390
421
  // ── surface tiers ─────────────────────────────────────────
@@ -616,7 +647,11 @@ describe("resolveKnobs", () => {
616
647
  // no className may reach RN views regardless of the knob.
617
648
  it("native emits no class for either value", async () => {
618
649
  vi.resetModules();
619
- vi.doMock("@multiplatform.one/platform", () => ({ isWeb: false }));
650
+ vi.doMock("@multiplatform.one/platform", () => ({
651
+ isWeb: false,
652
+ isTouchable: false,
653
+ isWebTouchable: false,
654
+ }));
620
655
  const { resolveKnobs: resolveNative } = await import("./resolveKnobs");
621
656
  try {
622
657
  for (const cornerSmoothing of ["round", "smooth"] as const) {
@@ -937,24 +972,24 @@ describe("applyDensity", () => {
937
972
  expect(applied.density).toBe("comfortable");
938
973
  });
939
974
 
940
- it("compact density steps size and space down one level", () => {
975
+ it("compact density steps space down one level and leaves size unchanged", () => {
941
976
  expect(
942
977
  applyDensity(knobsWith({ size: "large", space: "large", density: "compact" })),
943
- ).toMatchObject({ size: "medium", space: "medium", density: "compact" });
978
+ ).toMatchObject({ size: "large", space: "medium", density: "compact" });
944
979
  expect(
945
980
  applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
946
- ).toMatchObject({ size: "small", space: "small", density: "compact" });
981
+ ).toMatchObject({ size: "medium", space: "small", density: "compact" });
947
982
  expect(
948
983
  applyDensity(knobsWith({ size: "small", space: "small", density: "compact" })),
949
984
  ).toMatchObject({ size: "small", space: "small", density: "compact" });
950
985
  });
951
986
 
952
- it("compact:true override wins over comfortable density knob", () => {
987
+ it("compact:true override wins over comfortable density knob without changing size", () => {
953
988
  const applied = applyDensity(
954
989
  knobsWith({ size: "medium", space: "medium", density: "comfortable" }),
955
990
  true,
956
991
  );
957
- expect(applied).toMatchObject({ size: "small", space: "small", density: "compact" });
992
+ expect(applied).toMatchObject({ size: "medium", space: "small", density: "compact" });
958
993
  });
959
994
 
960
995
  it("compact:false override wins over compact density knob", () => {
@@ -970,17 +1005,42 @@ describe("applyDensity", () => {
970
1005
  expect(resolveKnobs(knobsWith({ density: "compact" })).knobProps.density).toBe("compact");
971
1006
  });
972
1007
 
973
- it("compact density produces smaller sizeToken and gap than comfortable", () => {
1008
+ it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
974
1009
  const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
975
1010
  const compact = resolveKnobs(
976
1011
  applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
977
1012
  );
978
1013
  expect(comfortable.knobProps.sizeToken).toBe("$4");
979
- expect(compact.knobProps.sizeToken).toBe("$3");
1014
+ expect(compact.knobProps.sizeToken).toBe("$4");
1015
+ expect(compact.knobProps.control.height).toBe(comfortable.knobProps.control.height);
980
1016
  expect(comfortable.knobProps.gap).toEqual({ gap: "$4" });
981
1017
  expect(compact.knobProps.gap).toEqual({ gap: "$2" });
982
1018
  });
983
1019
 
1020
+ it("pairs label type to size — never the control-height sizeToken", () => {
1021
+ expect(resolveKnobs(knobsWith({ size: "small" })).knobProps.label.fontSize).toBe("$2");
1022
+ expect(resolveKnobs(knobsWith({ size: "medium" })).knobProps.label.fontSize).toBe("$3");
1023
+ expect(resolveKnobs(knobsWith({ size: "large" })).knobProps.label.fontSize).toBe("$4");
1024
+ expect(resolveKnobs(defaultKnobs).knobProps.label.fontSize).not.toBe(
1025
+ resolveKnobs(defaultKnobs).knobProps.sizeToken,
1026
+ );
1027
+ });
1028
+
1029
+ it("nestedControl paints below the 44px floor and restores press with hitSlop", () => {
1030
+ const medium = resolveKnobs(knobsWith({ size: "medium" })).knobProps.nestedControl;
1031
+ expect(medium.px).toBe(32);
1032
+ expect(medium.width).toBe(32);
1033
+ expect(medium.maxWidth).toBe(32);
1034
+ expect(medium.hitSlop).toEqual({ top: 6, bottom: 6, left: 6, right: 6 });
1035
+
1036
+ const compact = resolveKnobs(applyDensity(knobsWith({ size: "medium", density: "compact" })))
1037
+ .knobProps.nestedControl;
1038
+ // Compact steps space only (LC-76) — nestedControl follows size, so
1039
+ // medium+compact stays on the medium nested box.
1040
+ expect(compact.px).toBe(32);
1041
+ expect(compact.hitSlop).toEqual({ top: 6, bottom: 6, left: 6, right: 6 });
1042
+ });
1043
+
984
1044
  it("compact density preserves house-decision knobs", () => {
985
1045
  const knobs = knobsWith({
986
1046
  density: "compact",
@@ -1152,3 +1212,25 @@ describe("disabledState recipe (LC-40)", () => {
1152
1212
  }
1153
1213
  });
1154
1214
  });
1215
+
1216
+ describe("LC-67 touch height floor via resolveKnobs", () => {
1217
+ it("paints control.height >= 44 when isTouchable", async () => {
1218
+ vi.resetModules();
1219
+ vi.doMock("@multiplatform.one/platform", () => ({
1220
+ isWeb: true,
1221
+ isTouchable: true,
1222
+ isWebTouchable: true,
1223
+ }));
1224
+ const { resolveKnobs: resolveTouch } = await import("./resolveKnobs");
1225
+ try {
1226
+ const small = resolveTouch(knobsWith({ size: "small" }));
1227
+ const medium = resolveTouch(knobsWith({ size: "medium" }));
1228
+ expect(small.knobProps.control.height).toBeGreaterThanOrEqual(44);
1229
+ expect(medium.knobProps.control.height).toBeGreaterThanOrEqual(44);
1230
+ expect(medium.knobProps.control.height).toBeGreaterThan(small.knobProps.control.height);
1231
+ } finally {
1232
+ vi.doUnmock("@multiplatform.one/platform");
1233
+ vi.resetModules();
1234
+ }
1235
+ });
1236
+ });
@@ -1,4 +1,4 @@
1
- import { isWeb } from "@multiplatform.one/platform";
1
+ import { isTouchable, isWeb, isWebTouchable } from "@multiplatform.one/platform";
2
2
  import type { FontSizeTokens, TransitionProp } from "tamagui";
3
3
  // Importing the class constant also mounts the corner-shape stylesheet at
4
4
  // theme load (web only, idempotent) — see cornerSmoothing.ts.
@@ -10,6 +10,7 @@ import {
10
10
  withDefaultStateLayer,
11
11
  } from "./focusState";
12
12
  import type { CornerSmoothing, Knobs, PageTitleScale, StateKnobs } from "./knobs";
13
+ import { pressTargetHitSlop } from "./layoutTokens";
13
14
  import type {
14
15
  ControlStateProps,
15
16
  DisabledRecipe,
@@ -20,10 +21,14 @@ import type {
20
21
  ResolvedKnobs,
21
22
  TextStateProps,
22
23
  } from "./recipes";
24
+ import { sizeRecipeForToken } from "./sizeRecipes";
23
25
 
24
26
  // ── Density (compact) stepping ────────────────────────────────
25
27
 
26
- /** Step size down one level for compact density. */
28
+ /**
29
+ * @deprecated Size is no longer coupled to density (LC-76). Compact density
30
+ * steps space only. Kept exported because figmaTokens historically imported it.
31
+ */
27
32
  export const compactSizeMap: Record<Knobs["size"], Knobs["size"]> = {
28
33
  small: "small",
29
34
  medium: "small",
@@ -40,9 +45,10 @@ export const compactSpaceMap: Record<Knobs["space"], Knobs["space"]> = {
40
45
  /**
41
46
  * Apply density mode to knobs before token resolution.
42
47
  *
43
- * - `density: "compact"` steps size + space down one level.
48
+ * - `density: "compact"` steps **space only** (`compactSpaceMap`). Size is an
49
+ * independent axis (LC-76) — compact must not shrink controls.
44
50
  * - `compactOverride` wins over the density knob when provided:
45
- * `true` forces compact stepping; `false` forces comfortable (no step).
51
+ * `true` forces compact space stepping; `false` forces comfortable (no step).
46
52
  */
47
53
  export function applyDensity(knobs: Knobs, compactOverride?: boolean): Knobs {
48
54
  const useCompact = compactOverride !== undefined ? compactOverride : knobs.density === "compact";
@@ -52,7 +58,6 @@ export function applyDensity(knobs: Knobs, compactOverride?: boolean): Knobs {
52
58
  return {
53
59
  ...knobs,
54
60
  density: "compact",
55
- size: compactSizeMap[knobs.size],
56
61
  space: compactSpaceMap[knobs.space],
57
62
  };
58
63
  }
@@ -180,6 +185,24 @@ const sizeTokenMap = {
180
185
  large: "$5",
181
186
  } as const;
182
187
 
188
+ /** Control-chrome type — one step below the matching sizeToken height. */
189
+ const labelFontSizeMap = {
190
+ small: "$2",
191
+ medium: "$3",
192
+ large: "$4",
193
+ } as const;
194
+
195
+ /**
196
+ * Painted size for nested hit-targets (calendar days). Always below the
197
+ * 44px floor; press is restored with hitSlop. Steps with `size` (compact
198
+ * density steps space only — LC-76 — so nested chrome follows the size axis).
199
+ */
200
+ const nestedControlPxMap = {
201
+ small: 28,
202
+ medium: 32,
203
+ large: 36,
204
+ } as const;
205
+
183
206
  const headingFontMap = {
184
207
  "sans-serif": "$heading",
185
208
  serif: "$serif",
@@ -340,6 +363,11 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
340
363
  const outerRadiusToken = outerRadiusMap[knobs.borderRadius];
341
364
  const borderWidthValue = borderWidthMap[knobs.borderWidth];
342
365
  const sizeToken = sizeTokenMap[knobs.size];
366
+ const nestedPx = nestedControlPxMap[knobs.size];
367
+ const nestedHitSlop = pressTargetHitSlop(nestedPx);
368
+ const sizeRecipe = sizeRecipeForToken(sizeToken, {
369
+ touch: isTouchable || isWebTouchable,
370
+ });
343
371
  const paddingToken = panelPaddingMap[knobs.space];
344
372
  const gapToken = gapMap[knobs.space];
345
373
  const fontWeightValue = fontWeightMap[knobs.fontWeight];
@@ -451,6 +479,28 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
451
479
  gap: { gap: gapToken },
452
480
  gapLg: { gap: gapLgMap[knobs.space] },
453
481
  sizeToken,
482
+ label: {
483
+ fontSize: labelFontSizeMap[knobs.size],
484
+ fontFamily: bodyFontValue,
485
+ fontWeight: fontWeightValue,
486
+ },
487
+ nestedControl: {
488
+ px: nestedPx,
489
+ width: nestedPx,
490
+ height: nestedPx,
491
+ minWidth: nestedPx,
492
+ maxWidth: nestedPx,
493
+ minHeight: nestedPx,
494
+ maxHeight: nestedPx,
495
+ hitSlop: nestedHitSlop,
496
+ },
497
+ control: {
498
+ height: sizeRecipe.height,
499
+ paddingHorizontal: sizeRecipe.paddingHorizontal,
500
+ gap: sizeRecipe.gap,
501
+ },
502
+ controlType: { fontSize: sizeRecipe.fontSize },
503
+ controlIcon: { width: sizeRecipe.iconSize, height: sizeRecipe.iconSize },
454
504
  heading: {
455
505
  fontFamily: headingFontMap[knobs.headingFont],
456
506
  fontWeight: fontWeightValue,