@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.
- package/package.json +18 -18
- package/src/dates.spec.ts +344 -1
- package/src/dates.ts +50 -3
- package/src/devWarn.ts +29 -1
- package/src/figma/figmaTokens.spec.ts +3 -3
- package/src/figma/figmaTokens.ts +6 -3
- package/src/keyboardFocusRing.ts +61 -5
- package/src/menuRow.spec.ts +2 -2
- package/src/menuRow.ts +5 -4
- package/src/theme/Surface.spec.tsx +146 -0
- package/src/theme/Surface.tsx +73 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
- package/src/theme/createDefaultThemeConfig.ts +47 -3
- package/src/theme/createThemes.ts +27 -4
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
- package/src/theme/focusState.ts +9 -1
- package/src/theme/groupGeometry.spec.ts +108 -0
- package/src/theme/groupGeometry.ts +119 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +17 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/layoutTokens.spec.ts +14 -0
- package/src/theme/layoutTokens.ts +29 -0
- package/src/theme/recipeInputs.ts +368 -0
- package/src/theme/recipes.ts +34 -0
- package/src/theme/resolveKnobs.spec.ts +91 -9
- package/src/theme/resolveKnobs.ts +55 -5
- package/src/theme/shared.tsx +11 -0
- package/src/theme/sizeRecipes.spec.ts +296 -0
- package/src/theme/sizeRecipes.ts +198 -0
- package/src/theme/useResolvedKnobs.ts +76 -24
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
- package/types/dates.d.ts +36 -2
- package/types/dates.d.ts.map +1 -1
- package/types/devWarn.d.ts +16 -1
- package/types/devWarn.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +19 -4
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/menuRow.d.ts +5 -4
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +37 -0
- package/types/theme/Surface.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +14 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +8 -0
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/groupGeometry.d.ts +69 -0
- package/types/theme/groupGeometry.d.ts.map +1 -0
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +2 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +22 -0
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +110 -0
- package/types/theme/recipeInputs.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +47 -0
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +7 -3
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/shared.d.ts +9 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +68 -0
- package/types/theme/sizeRecipes.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +10 -6
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- 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
|
+
}
|
package/src/theme/recipes.ts
CHANGED
|
@@ -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", () => ({
|
|
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", () => ({
|
|
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
|
|
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: "
|
|
978
|
+
).toMatchObject({ size: "large", space: "medium", density: "compact" });
|
|
944
979
|
expect(
|
|
945
980
|
applyDensity(knobsWith({ size: "medium", space: "medium", density: "compact" })),
|
|
946
|
-
).toMatchObject({ size: "
|
|
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: "
|
|
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
|
|
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("$
|
|
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
|
-
/**
|
|
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
|
|
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,
|