@multiplatform.one/theme 7.4.1 → 7.5.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 (106) hide show
  1. package/README.md +2 -1
  2. package/package.json +5 -5
  3. package/src/figma/figmaTokens.spec.ts +25 -21
  4. package/src/figma/figmaTokens.ts +2 -2
  5. package/src/font.ts +6 -19
  6. package/src/keyboardFocusRing.ts +12 -6
  7. package/src/theme/FontKnobStyles.tsx +5 -3
  8. package/src/theme/Tint.tsx +3 -2
  9. package/src/theme/animations/css.ts +14 -1
  10. package/src/theme/colorRules.spec.ts +233 -2
  11. package/src/theme/colorRules.ts +118 -0
  12. package/src/theme/cornerSmoothing.native.ts +39 -2
  13. package/src/theme/cornerSmoothing.spec.ts +213 -3
  14. package/src/theme/cornerSmoothing.ts +132 -10
  15. package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
  16. package/src/theme/createDefaultThemeConfig.ts +27 -8
  17. package/src/theme/defaults/builderOptions.ts +23 -10
  18. package/src/theme/defaults/categoryFonts.ts +21 -0
  19. package/src/theme/defaults/fonts.spec.ts +93 -10
  20. package/src/theme/defaults/fonts.theme.spec.tsx +128 -0
  21. package/src/theme/defaults/fonts.ts +155 -27
  22. package/src/theme/defaults/index.ts +1 -0
  23. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
  24. package/src/theme/focusState.spec.ts +110 -1
  25. package/src/theme/focusState.ts +32 -7
  26. package/src/theme/fontCategoryStacks.ts +24 -0
  27. package/src/theme/fontKnobStyles.spec.ts +15 -2
  28. package/src/theme/glyphPaint.spec.ts +26 -0
  29. package/src/theme/glyphPaint.ts +46 -0
  30. package/src/theme/index.ts +3 -0
  31. package/src/theme/knobs.ts +18 -2
  32. package/src/theme/layoutTokens.spec.ts +3 -1
  33. package/src/theme/layoutTokens.ts +47 -4
  34. package/src/theme/mpo13Proof.spec.ts +3 -3
  35. package/src/theme/mpo19Proof.spec.ts +285 -0
  36. package/src/theme/presets.ts +1 -1
  37. package/src/theme/radiusClass.spec.ts +394 -0
  38. package/src/theme/radiusClass.ts +203 -4
  39. package/src/theme/radiusClassTable.generated.ts +121 -0
  40. package/src/theme/recipeInputs.ts +223 -36
  41. package/src/theme/recipes.ts +68 -4
  42. package/src/theme/resolveKnobs.spec.ts +139 -11
  43. package/src/theme/resolveKnobs.ts +190 -25
  44. package/src/theme/sizeLadder.spec.ts +84 -0
  45. package/src/theme/sizeRecipes.spec.ts +19 -6
  46. package/src/theme/sizeRecipes.ts +43 -13
  47. package/src/theme/theme.native.tsx +2 -0
  48. package/src/theme/theme.provider.spec.ts +9 -0
  49. package/src/theme/theme.tsx +6 -3
  50. package/src/theme/tint.spec.tsx +36 -35
  51. package/src/theme/useResolvedKnobs.ts +6 -3
  52. package/types/figma/figmaTokens.d.ts +1 -1
  53. package/types/figma/figmaTokens.d.ts.map +1 -1
  54. package/types/font.d.ts.map +1 -1
  55. package/types/keyboardFocusRing.d.ts +11 -5
  56. package/types/keyboardFocusRing.d.ts.map +1 -1
  57. package/types/theme/FontKnobStyles.d.ts +2 -1
  58. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  59. package/types/theme/Tint.d.ts +2 -1
  60. package/types/theme/Tint.d.ts.map +1 -1
  61. package/types/theme/animations/css.d.ts +15 -2
  62. package/types/theme/animations/css.d.ts.map +1 -1
  63. package/types/theme/colorRules.d.ts +59 -1
  64. package/types/theme/colorRules.d.ts.map +1 -1
  65. package/types/theme/cornerSmoothing.d.ts +42 -20
  66. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  67. package/types/theme/cornerSmoothing.native.d.ts +22 -2
  68. package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
  69. package/types/theme/createDefaultThemeConfig.d.ts +21 -3
  70. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  71. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  72. package/types/theme/defaults/categoryFonts.d.ts +19 -0
  73. package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
  74. package/types/theme/defaults/fonts.d.ts +135 -2
  75. package/types/theme/defaults/fonts.d.ts.map +1 -1
  76. package/types/theme/defaults/index.d.ts +1 -0
  77. package/types/theme/defaults/index.d.ts.map +1 -1
  78. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  79. package/types/theme/focusState.d.ts +32 -6
  80. package/types/theme/focusState.d.ts.map +1 -1
  81. package/types/theme/fontCategoryStacks.d.ts +11 -0
  82. package/types/theme/fontCategoryStacks.d.ts.map +1 -0
  83. package/types/theme/glyphPaint.d.ts +16 -0
  84. package/types/theme/glyphPaint.d.ts.map +1 -0
  85. package/types/theme/index.d.ts +3 -0
  86. package/types/theme/index.d.ts.map +1 -1
  87. package/types/theme/knobs.d.ts +10 -2
  88. package/types/theme/knobs.d.ts.map +1 -1
  89. package/types/theme/layoutTokens.d.ts +46 -4
  90. package/types/theme/layoutTokens.d.ts.map +1 -1
  91. package/types/theme/radiusClass.d.ts +96 -4
  92. package/types/theme/radiusClass.d.ts.map +1 -1
  93. package/types/theme/radiusClassTable.generated.d.ts +146 -0
  94. package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
  95. package/types/theme/recipeInputs.d.ts +85 -14
  96. package/types/theme/recipeInputs.d.ts.map +1 -1
  97. package/types/theme/recipes.d.ts +78 -4
  98. package/types/theme/recipes.d.ts.map +1 -1
  99. package/types/theme/resolveKnobs.d.ts +12 -1
  100. package/types/theme/resolveKnobs.d.ts.map +1 -1
  101. package/types/theme/sizeRecipes.d.ts +27 -14
  102. package/types/theme/sizeRecipes.d.ts.map +1 -1
  103. package/types/theme/theme.d.ts.map +1 -1
  104. package/types/theme/theme.native.d.ts.map +1 -1
  105. package/types/theme/useResolvedKnobs.d.ts +2 -2
  106. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * GENERATED from docs/design-guidelines.md §13 "Radius resolution" (DG-RAD-01..05)
3
+ * by scripts/radius-class-registry.mjs — DO NOT EDIT BY HAND.
4
+ *
5
+ * Regenerate: node scripts/radius-class-registry.mjs --emit
6
+ * Verify: node scripts/radius-class-registry.mjs --check
7
+ *
8
+ * The doctrine is the source; this module is the resolver's copy of it. Editing the
9
+ * resolution here instead of in the doctrine is exactly the drift the generator exists
10
+ * to prevent (the same contract as scripts/radius-identity-registry.mjs).
11
+ */
12
+
13
+ export const RADIUS_RESOLUTION_STOPS = ["none", "small", "medium", "large", "full"] as const;
14
+
15
+ /** The `borderRadius` knob stops as the doctrine table spells them. */
16
+ export type RadiusResolutionStop = (typeof RADIUS_RESOLUTION_STOPS)[number];
17
+
18
+ /**
19
+ * One doctrine table cell. `px` is a literal resolved radius; `half-height` is the
20
+ * part's own painted height over two (a true circle/pill); `min-token-padding` and
21
+ * `padding` are the CONTAINER-CAP spellings (Axiom 1 CLIP: never exceed own padding).
22
+ */
23
+ export type RadiusResolutionCell =
24
+ | { readonly kind: "px"; readonly px: number }
25
+ | { readonly kind: "half-height" }
26
+ | { readonly kind: "min-token-padding"; readonly tokenPx: number }
27
+ | { readonly kind: "padding" };
28
+
29
+ export type RadiusResolutionRow = Readonly<Record<RadiusResolutionStop, RadiusResolutionCell>>;
30
+
31
+ /** DG-RAD-01..04 — resolved radius per class per stop. */
32
+ export const RADIUS_RESOLUTION_TABLE = {
33
+ DEFAULT: {
34
+ none: { kind: "px", px: 0 },
35
+ small: { kind: "px", px: 5 },
36
+ medium: { kind: "px", px: 9 },
37
+ large: { kind: "px", px: 16 },
38
+ full: { kind: "px", px: 50 },
39
+ },
40
+ "CIRCULAR-AT-FULL": {
41
+ none: { kind: "px", px: 0 },
42
+ small: { kind: "px", px: 5 },
43
+ medium: { kind: "px", px: 9 },
44
+ large: { kind: "px", px: 16 },
45
+ full: { kind: "half-height" },
46
+ },
47
+ BINARY: {
48
+ none: { kind: "px", px: 0 },
49
+ small: { kind: "half-height" },
50
+ medium: { kind: "half-height" },
51
+ large: { kind: "half-height" },
52
+ full: { kind: "half-height" },
53
+ },
54
+ "CONTAINER-CAP": {
55
+ none: { kind: "px", px: 0 },
56
+ small: { kind: "min-token-padding", tokenPx: 5 },
57
+ medium: { kind: "min-token-padding", tokenPx: 9 },
58
+ large: { kind: "min-token-padding", tokenPx: 16 },
59
+ full: { kind: "padding" },
60
+ },
61
+ } as const satisfies Record<string, RadiusResolutionRow>;
62
+
63
+ /** A part is in exactly one class; DEFAULT unless the doctrine assigns otherwise. */
64
+ export type RadiusResolutionClass = keyof typeof RADIUS_RESOLUTION_TABLE;
65
+
66
+ export interface RadiusOverrideEntry {
67
+ /** the component name as the doctrine's override table spells it */
68
+ readonly component: string;
69
+ readonly cells: RadiusResolutionRow;
70
+ /** the recorded reason the class would destroy meaning (the DG-RAD-05 bar) */
71
+ readonly why: string;
72
+ }
73
+
74
+ /**
75
+ * DG-RAD-05 — declared per-component overrides. A row here WINS over the part's
76
+ * resolution class, and sweeps PASS these measurements instead of flagging them.
77
+ */
78
+ export const RADIUS_OVERRIDE_TABLE: readonly RadiusOverrideEntry[] = [
79
+ {
80
+ component: "Checkbox",
81
+ cells: {
82
+ none: { kind: "px", px: 0 },
83
+ small: { kind: "px", px: 5 },
84
+ medium: { kind: "px", px: 5 },
85
+ large: { kind: "px", px: 5 },
86
+ full: { kind: "px", px: 5 },
87
+ },
88
+ why: "CIRCULAR-AT-FULL kin by shape (20×20), but a circular checkbox reads as a radio — a semantic collision. Clamped at `$2` = 5, never h/2 (R2b DECIDED 2026-08-28: \"NO. Checkbox never becomes full circle.\")",
89
+ },
90
+ {
91
+ component: "Radio disc",
92
+ cells: {
93
+ none: { kind: "half-height" },
94
+ small: { kind: "half-height" },
95
+ medium: { kind: "half-height" },
96
+ large: { kind: "half-height" },
97
+ full: { kind: "half-height" },
98
+ },
99
+ why: "R2b inverted: a square radio reads as a checkbox — the identical collision, mirrored. The disc's circle IS its meaning, so it stays round at every stop including `none` (R2c DECIDED 2026-08-28: always round — identity)",
100
+ },
101
+ ];
102
+
103
+ /**
104
+ * The resolver's layering, as doctrine spells it: a declared knob-immunity
105
+ * (R-PILL / R-IDENTITY, LC-60/D-09) wins, then a declared override, then the class.
106
+ */
107
+ export const RADIUS_RESOLUTION_COMPOSITION = ["immunity", "override", "class"] as const;
108
+
109
+ export interface OpenRadiusAdjudication {
110
+ readonly id: string;
111
+ readonly text: string;
112
+ }
113
+
114
+ /**
115
+ * Adjudications still open (MPO-56 AC-6): sweeps must EXCLUDE these parts
116
+ * from cell assertions and REPORT the exclusion — never silently skip it.
117
+ * An empty list is the closed state (R2 leftovers closed by RULINGS-2026-08-28).
118
+ */
119
+ export const OPEN_RADIUS_ADJUDICATIONS: readonly OpenRadiusAdjudication[] = [
120
+
121
+ ];
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Size-recipe INPUTS (LC-70 RECIPE-INPUTS).
2
+ * Size-recipe INPUTS (LC-80 RECIPE-INPUTS).
3
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.
4
+ * Stored numbers are the source of truth. Derived projections (heights,
5
+ * recipes, box/type/icon variants, touch heights, radii) are never
6
+ * handwritten at a call site. The DEFAULT ladder is the measured Tamagui
7
+ * v5 table (`tokenTable`): padX / font / icon / radius per size token,
8
+ * gap == radius at every stop. A linear ratio is only the fallback when
9
+ * a theme sets `{ baseHeight }` and drops the table — it is not the
10
+ * default ladder and is not a signed-off divergence (MPO-46 / R6).
11
+ *
12
+ * Radius steps with the size token. The borderRadius knob is an override
13
+ * that retargets gap through `applyRadiusStop` (gap == radius at every
14
+ * knob stop too). `radiusPx` on inputs is the $4 / medium default (9).
8
15
  *
9
16
  * Tamagui `createThemes` / theme-builder still emits COLOR palettes only.
10
17
  * Do not stuff recipe keys into `getTheme()` / `themes`.
11
- *
12
- * DEFAULT uses `tokenHeights` pinned to the stock Tamagui `$size` scale as
13
- * measured on tamagui.dev ($4=44, $3=36, $5=52, $6=64 — MPO-13 C1/C2: the
14
- * recipe key and the raw token now agree). `{ baseHeight: 40 }` (no
15
- * tokenHeights / sizeFactors) regenerates geometrically from
16
- * `baseHeight * stepRatio ** step`.
17
18
  */
18
19
 
19
20
  import {
@@ -50,6 +51,31 @@ export type SizeRecipeRatios = {
50
51
  gap: number;
51
52
  };
52
53
 
54
+ /** Per-token channels on the measured ladder. Gap is not stored — it equals `radius`. */
55
+ export type SizeRecipeTokenChannels = {
56
+ paddingHorizontal: number;
57
+ fontSize: number;
58
+ iconSize: number;
59
+ radius: number;
60
+ };
61
+
62
+ export type SizeRecipeTokenTable = Partial<Record<SizeRecipeToken, SizeRecipeTokenChannels>>;
63
+
64
+ /**
65
+ * borderRadius knob stops (px). Mirrors the v5 radius tokens the knob
66
+ * already resolves ($0/$2/$4/$6/$12 = 0/5/9/16/50). `applyRadiusStop`
67
+ * retargets recipe.gap to the same number so gap == radius at every stop.
68
+ */
69
+ export const SIZE_RECIPE_RADIUS_STOPS = {
70
+ none: 0,
71
+ small: 5,
72
+ medium: 9,
73
+ large: 16,
74
+ full: 50,
75
+ } as const;
76
+
77
+ export type SizeRecipeRadiusStop = keyof typeof SIZE_RECIPE_RADIUS_STOPS;
78
+
53
79
  export type SizeRecipeInputs = {
54
80
  baseHeight: number;
55
81
  stepRatio: number;
@@ -61,6 +87,21 @@ export type SizeRecipeInputs = {
61
87
  * `tokenHeights` is omitted. Factors are `stepRatio ** exponent` otherwise.
62
88
  */
63
89
  sizeFactors?: Partial<Record<SizeRecipeToken, number>>;
90
+ /**
91
+ * Measured per-token channels (padX / font / icon / radius). When
92
+ * present, enumerable recipes read this table and set gap = radius.
93
+ * `{ baseHeight }` without an explicit tokenTable drops it so the
94
+ * geometric fallback can run.
95
+ */
96
+ tokenTable?: SizeRecipeTokenTable;
97
+ /** Same-height tighter family (Chip / `recipeFamily="controlCompact"`). */
98
+ compactTokenTable?: SizeRecipeTokenTable;
99
+ /**
100
+ * Input padX (one space half-step below the button at the same token).
101
+ * $4 = 16 vs the button's 18. Stored on inputs so the generator remains
102
+ * the single writer; Input consumes this, not a second handwritten map.
103
+ */
104
+ inputPadX?: Partial<Record<SizeRecipeToken, number>>;
64
105
  ratios: SizeRecipeRatios;
65
106
  compactRatios: SizeRecipeRatios;
66
107
  radiusPx: number;
@@ -103,6 +144,8 @@ export type SizeRecipeTables = {
103
144
  typeVariants: Record<string, SizeRecipeTypeVariant>;
104
145
  iconVariants: Record<string, SizeRecipeIconVariant>;
105
146
  heightVariants: Record<string, { height: number }>;
147
+ /** Size-stepped radius (px). Gap on the matching recipe equals this value. */
148
+ radii: Record<string, number>;
106
149
  };
107
150
 
108
151
  export type SizeRecipeFamily = SizeRecipeTables & { name: SizeRecipeFamilyName };
@@ -118,6 +161,7 @@ export type GeneratedSizeRecipes = {
118
161
  boxVariants: Record<string, SizeRecipeBoxVariant>;
119
162
  typeVariants: Record<string, SizeRecipeTypeVariant>;
120
163
  iconVariants: Record<string, SizeRecipeIconVariant>;
164
+ radii: Record<string, number>;
121
165
  families: RecipeFamilies;
122
166
  /** Tile-size ladder (MPO-31) — derived from `inputs.tile` over the measured defaults. */
123
167
  tiles: TileSizeLadder;
@@ -168,40 +212,100 @@ export const defaultSizeFactors: Record<SizeRecipeToken, number> = {
168
212
  $6: 64 / 44,
169
213
  };
170
214
 
171
- // Anchored at the measured $4/$true control: height 44, padX 18
172
- // ($space.true), font 14, icon 16, gap 9 (gap tracks $radius.true). The
173
- // ratio model is linear, so steps away from $4 approximate Tamagui's
174
- // hand-tuned ramp rather than matching it exactly.
175
- const DEFAULT_RATIOS: SizeRecipeRatios = {
215
+ /**
216
+ * Measured Tamagui v5 Button ladder (rendered reference §6.1) plus the
217
+ * integer token-scale fills for stops the site did not photograph.
218
+ *
219
+ * padX = `$space` at the same key (2/4/7/10/13/16/18/24/32).
220
+ * radius = `$radius` at the same key (3/5/7/9/10/16); half-steps sit
221
+ * halfway on that scale (4/6/8) because v5 has no $1.5/$2.5/$3.5 radius.
222
+ * font = Inter body at the same key (11/12/13/14/16/18); half-steps take
223
+ * the next integer stop.
224
+ * icon steps with size and keeps the existing $4 = 16 contract.
225
+ * $2/$3/$4/$6 padX / font / radius are the photographed values (7/13/18/32,
226
+ * 12/13/14/18, 5/7/9/16).
227
+ */
228
+ export const TAMAGUI_CONTROL_SIZE_TABLE: Record<SizeRecipeToken, SizeRecipeTokenChannels> = {
229
+ $1: { paddingHorizontal: 2, fontSize: 11, iconSize: 10, radius: 3 },
230
+ "$1.5": { paddingHorizontal: 4, fontSize: 12, iconSize: 11, radius: 4 },
231
+ $2: { paddingHorizontal: 7, fontSize: 12, iconSize: 12, radius: 5 },
232
+ "$2.5": { paddingHorizontal: 10, fontSize: 13, iconSize: 14, radius: 6 },
233
+ $3: { paddingHorizontal: 13, fontSize: 13, iconSize: 14, radius: 7 },
234
+ "$3.5": { paddingHorizontal: 16, fontSize: 14, iconSize: 15, radius: 8 },
235
+ $4: { paddingHorizontal: 18, fontSize: 14, iconSize: 16, radius: 9 },
236
+ $true: { paddingHorizontal: 18, fontSize: 14, iconSize: 16, radius: 9 },
237
+ $5: { paddingHorizontal: 24, fontSize: 16, iconSize: 18, radius: 10 },
238
+ $6: { paddingHorizontal: 32, fontSize: 18, iconSize: 20, radius: 16 },
239
+ };
240
+
241
+ /**
242
+ * Input padX — `getSpace(val, { shift: -1 })` on the v5 space scale
243
+ * (rendered reference §6.2). $2/$3/$4 are the photographed 4/10/16;
244
+ * $4 is 16 against the button's 18.
245
+ */
246
+ export const TAMAGUI_INPUT_PADX: Record<SizeRecipeToken, number> = {
247
+ $1: 1,
248
+ "$1.5": 2,
249
+ $2: 4,
250
+ "$2.5": 7,
251
+ $3: 10,
252
+ "$3.5": 13,
253
+ $4: 16,
254
+ $true: 16,
255
+ $5: 21,
256
+ $6: 24,
257
+ };
258
+
259
+ /**
260
+ * controlCompact: same height / radius (so gap == radius) as control;
261
+ * padX is the Input table (one space half-step tighter); font/icon sit
262
+ * one type step below the control table.
263
+ */
264
+ export const TAMAGUI_COMPACT_SIZE_TABLE: Record<SizeRecipeToken, SizeRecipeTokenChannels> = {
265
+ $1: { paddingHorizontal: 1, fontSize: 10, iconSize: 9, radius: 3 },
266
+ "$1.5": { paddingHorizontal: 2, fontSize: 11, iconSize: 10, radius: 4 },
267
+ $2: { paddingHorizontal: 4, fontSize: 11, iconSize: 11, radius: 5 },
268
+ "$2.5": { paddingHorizontal: 7, fontSize: 12, iconSize: 12, radius: 6 },
269
+ $3: { paddingHorizontal: 10, fontSize: 12, iconSize: 13, radius: 7 },
270
+ "$3.5": { paddingHorizontal: 13, fontSize: 13, iconSize: 14, radius: 8 },
271
+ $4: { paddingHorizontal: 16, fontSize: 13, iconSize: 14, radius: 9 },
272
+ $true: { paddingHorizontal: 16, fontSize: 13, iconSize: 14, radius: 9 },
273
+ $5: { paddingHorizontal: 21, fontSize: 14, iconSize: 16, radius: 10 },
274
+ $6: { paddingHorizontal: 24, fontSize: 16, iconSize: 18, radius: 16 },
275
+ };
276
+
277
+ // Geometric fallback only — used when `{ baseHeight }` drops tokenTable.
278
+ // Anchored at the measured $4 so a doubled baseHeight still doubles $4.
279
+ const FALLBACK_RATIOS: SizeRecipeRatios = {
176
280
  padX: 18 / 44,
177
281
  font: 14 / 44,
178
282
  icon: 16 / 44,
179
283
  gap: 9 / 44,
180
284
  };
181
285
 
182
- // One space step and one type step below control at the same height:
183
- // $4 compact = padX 13 ($space.3), font 13 ($3 label), icon 14, gap 7
184
- // ($space.2).
185
- const DEFAULT_COMPACT_RATIOS: SizeRecipeRatios = {
186
- padX: 13 / 44,
286
+ const FALLBACK_COMPACT_RATIOS: SizeRecipeRatios = {
287
+ padX: 16 / 44,
187
288
  font: 13 / 44,
188
289
  icon: 14 / 44,
189
- gap: 7 / 44,
290
+ gap: 9 / 44,
190
291
  };
191
292
 
192
293
  export const DEFAULT_SIZE_RECIPE_INPUTS: SizeRecipeInputs = {
193
294
  // Tamagui `$size.true` = 44 — the iOS touch minimum, measured on every
194
- // tamagui.dev button and input (MPO-13 C2; was 32).
295
+ // tamagui.dev button and input (MPO-13 C2).
195
296
  baseHeight: 44,
196
297
  stepRatio: 1.25,
197
298
  tokenSteps: { ...SIZE_TOKEN_EXPONENTS },
198
299
  tokenHeights: { ...DEFAULT_TOKEN_HEIGHTS },
199
300
  sizeFactors: { ...defaultSizeFactors },
200
- ratios: { ...DEFAULT_RATIOS },
201
- compactRatios: { ...DEFAULT_COMPACT_RATIOS },
202
- // Tamagui `$radius.true` = 9, matching the borderRadius knob's medium
203
- // stop ($4 = 9px) — the stored input no longer contradicts the knob
204
- // (MPO-13 C3; was 6).
301
+ tokenTable: { ...TAMAGUI_CONTROL_SIZE_TABLE },
302
+ compactTokenTable: { ...TAMAGUI_COMPACT_SIZE_TABLE },
303
+ inputPadX: { ...TAMAGUI_INPUT_PADX },
304
+ ratios: { ...FALLBACK_RATIOS },
305
+ compactRatios: { ...FALLBACK_COMPACT_RATIOS },
306
+ // Tamagui `$radius.true` = 9 — the $4 / medium default. Per-token
307
+ // radii live on tokenTable; this is the knob's medium stop and the
308
+ // $4 table row, not a flat radius for every size.
205
309
  radiusPx: 9,
206
310
  touchFloor: 44,
207
311
  };
@@ -212,12 +316,31 @@ export const TOUCH_HEIGHT_FLOOR = DEFAULT_SIZE_RECIPE_INPUTS.touchFloor;
212
316
 
213
317
  const MD_TOUCH_MIN = 48;
214
318
 
319
+ function cloneTokenTable(table?: SizeRecipeTokenTable): SizeRecipeTokenTable | undefined {
320
+ if (!table) return undefined;
321
+ const next: SizeRecipeTokenTable = {};
322
+ for (const token of SIZE_RECIPE_TOKENS) {
323
+ const row = table[token];
324
+ if (row) next[token] = { ...row };
325
+ }
326
+ return next;
327
+ }
328
+
329
+ function clonePadX(
330
+ table?: Partial<Record<SizeRecipeToken, number>>,
331
+ ): Partial<Record<SizeRecipeToken, number>> | undefined {
332
+ return table ? { ...table } : undefined;
333
+ }
334
+
215
335
  export function cloneSizeRecipeInputs(inputs: SizeRecipeInputs): SizeRecipeInputs {
216
336
  return {
217
337
  ...inputs,
218
338
  tokenSteps: { ...inputs.tokenSteps },
219
339
  tokenHeights: inputs.tokenHeights ? { ...inputs.tokenHeights } : undefined,
220
340
  sizeFactors: inputs.sizeFactors ? { ...inputs.sizeFactors } : undefined,
341
+ tokenTable: cloneTokenTable(inputs.tokenTable),
342
+ compactTokenTable: cloneTokenTable(inputs.compactTokenTable),
343
+ inputPadX: clonePadX(inputs.inputPadX),
221
344
  ratios: { ...inputs.ratios },
222
345
  compactRatios: { ...inputs.compactRatios },
223
346
  tile: inputs.tile ? cloneTileSizeInputs(inputs.tile) : undefined,
@@ -225,14 +348,17 @@ export function cloneSizeRecipeInputs(inputs: SizeRecipeInputs): SizeRecipeInput
225
348
  }
226
349
 
227
350
  /**
228
- * Fill defaults. `{ baseHeight: 36 }` drops handwritten tokenHeights/sizeFactors
229
- * so every token regenerates from stepRatio.
351
+ * Fill defaults. `{ baseHeight: 36 }` drops handwritten tokenHeights /
352
+ * sizeFactors / token tables so every token regenerates from stepRatio.
230
353
  */
231
354
  export function resolveSizeRecipeInputs(partial: Partial<SizeRecipeInputs> = {}): SizeRecipeInputs {
232
355
  const scalingOverride =
233
356
  partial.baseHeight != null || partial.stepRatio != null || partial.tokenSteps != null;
234
357
  const hasTokenHeights = Object.prototype.hasOwnProperty.call(partial, "tokenHeights");
235
358
  const hasSizeFactors = Object.prototype.hasOwnProperty.call(partial, "sizeFactors");
359
+ const hasTokenTable = Object.prototype.hasOwnProperty.call(partial, "tokenTable");
360
+ const hasCompactTable = Object.prototype.hasOwnProperty.call(partial, "compactTokenTable");
361
+ const hasInputPadX = Object.prototype.hasOwnProperty.call(partial, "inputPadX");
236
362
 
237
363
  const resolved: SizeRecipeInputs = {
238
364
  baseHeight: partial.baseHeight ?? DEFAULT_SIZE_RECIPE_INPUTS.baseHeight,
@@ -260,6 +386,26 @@ export function resolveSizeRecipeInputs(partial: Partial<SizeRecipeInputs> = {})
260
386
  resolved.sizeFactors = { ...DEFAULT_SIZE_RECIPE_INPUTS.sizeFactors };
261
387
  }
262
388
 
389
+ if (hasTokenTable) {
390
+ resolved.tokenTable = partial.tokenTable ? cloneTokenTable(partial.tokenTable) : undefined;
391
+ } else if (!scalingOverride) {
392
+ resolved.tokenTable = cloneTokenTable(DEFAULT_SIZE_RECIPE_INPUTS.tokenTable);
393
+ }
394
+
395
+ if (hasCompactTable) {
396
+ resolved.compactTokenTable = partial.compactTokenTable
397
+ ? cloneTokenTable(partial.compactTokenTable)
398
+ : undefined;
399
+ } else if (!scalingOverride) {
400
+ resolved.compactTokenTable = cloneTokenTable(DEFAULT_SIZE_RECIPE_INPUTS.compactTokenTable);
401
+ }
402
+
403
+ if (hasInputPadX) {
404
+ resolved.inputPadX = partial.inputPadX ? clonePadX(partial.inputPadX) : undefined;
405
+ } else if (!scalingOverride) {
406
+ resolved.inputPadX = clonePadX(DEFAULT_SIZE_RECIPE_INPUTS.inputPadX);
407
+ }
408
+
263
409
  return resolved;
264
410
  }
265
411
 
@@ -282,15 +428,37 @@ export function sizeRecipeFromHeight(
282
428
  height: number,
283
429
  ratios: SizeRecipeRatios = DEFAULT_SIZE_RECIPE_INPUTS.ratios,
284
430
  ): SizeRecipe {
431
+ const gap = Math.round(height * ratios.gap);
285
432
  return {
286
433
  height,
287
434
  paddingHorizontal: Math.round(height * ratios.padX),
288
435
  fontSize: Math.round(height * ratios.font),
289
436
  iconSize: Math.round(height * ratios.icon),
290
- gap: Math.round(height * ratios.gap),
437
+ gap,
291
438
  };
292
439
  }
293
440
 
441
+ export function recipeFromTokenChannels(
442
+ height: number,
443
+ channels: SizeRecipeTokenChannels,
444
+ ): SizeRecipe {
445
+ return {
446
+ height,
447
+ paddingHorizontal: channels.paddingHorizontal,
448
+ fontSize: channels.fontSize,
449
+ iconSize: channels.iconSize,
450
+ gap: channels.radius,
451
+ };
452
+ }
453
+
454
+ /** Retarget inner gap to a borderRadius knob stop so gap == that stop's radius. */
455
+ export function applyRadiusStop<T extends { gap: number }>(
456
+ recipe: T,
457
+ stop: SizeRecipeRadiusStop,
458
+ ): T {
459
+ return { ...recipe, gap: SIZE_RECIPE_RADIUS_STOPS[stop] };
460
+ }
461
+
294
462
  function generateTouchHeights(
295
463
  heights: Record<string, number>,
296
464
  inputs: SizeRecipeInputs,
@@ -326,9 +494,18 @@ function generateTouchHeights(
326
494
  return heightsTouch;
327
495
  }
328
496
 
497
+ function channelsForToken(
498
+ token: string,
499
+ table: SizeRecipeTokenTable | undefined,
500
+ ): SizeRecipeTokenChannels | undefined {
501
+ if (!table) return undefined;
502
+ return table[token as SizeRecipeToken] ?? (token === "$true" ? table.$4 : undefined);
503
+ }
504
+
329
505
  export function generateSizeRecipeTables(
330
506
  inputs: SizeRecipeInputs,
331
507
  ratios: SizeRecipeRatios = inputs.ratios,
508
+ table: SizeRecipeTokenTable | undefined = inputs.tokenTable,
332
509
  ): SizeRecipeTables {
333
510
  const tokens = SIZE_RECIPE_TOKENS;
334
511
  const heights: Record<string, number> = {};
@@ -346,11 +523,17 @@ export function generateSizeRecipeTables(
346
523
  const typeVariants: Record<string, SizeRecipeTypeVariant> = {};
347
524
  const iconVariants: Record<string, SizeRecipeIconVariant> = {};
348
525
  const heightVariants: Record<string, { height: number }> = {};
526
+ const radii: Record<string, number> = {};
349
527
 
350
528
  for (const token of tokens) {
351
- const recipe = sizeRecipeFromHeight(heights[token], ratios);
529
+ const channels = channelsForToken(token, table);
530
+ const recipe = channels
531
+ ? recipeFromTokenChannels(heights[token], channels)
532
+ : sizeRecipeFromHeight(heights[token], ratios);
533
+ const radius = channels ? channels.radius : recipe.gap;
352
534
  recipes[token] = recipe;
353
535
  recipesTouch[token] = sizeRecipeFromHeight(touchHeights[token], ratios);
536
+ radii[token] = radius;
354
537
  boxVariants[token] = {
355
538
  height: recipe.height,
356
539
  paddingHorizontal: recipe.paddingHorizontal,
@@ -372,6 +555,7 @@ export function generateSizeRecipeTables(
372
555
  typeVariants,
373
556
  iconVariants,
374
557
  heightVariants,
558
+ radii,
375
559
  };
376
560
  }
377
561
 
@@ -379,10 +563,12 @@ export function createRecipeFamilies(
379
563
  inputs: SizeRecipeInputs | Partial<SizeRecipeInputs> = {},
380
564
  ): RecipeFamilies {
381
565
  const resolved = cloneSizeRecipeInputs(resolveSizeRecipeInputs(inputs));
382
- const control = generateSizeRecipeTables(resolved, resolved.ratios);
383
- // Same heights as control; tighter pad/font/icon/gap via compactRatios
384
- // (padX 8/32, font 13/32, icon 14/32, gap 4/32). Not a 28px family.
385
- const controlCompact = generateSizeRecipeTables(resolved, resolved.compactRatios);
566
+ const control = generateSizeRecipeTables(resolved, resolved.ratios, resolved.tokenTable);
567
+ const controlCompact = generateSizeRecipeTables(
568
+ resolved,
569
+ resolved.compactRatios,
570
+ resolved.compactTokenTable,
571
+ );
386
572
  return {
387
573
  control: { ...control, name: "control" },
388
574
  controlCompact: { ...controlCompact, name: "controlCompact" },
@@ -402,6 +588,7 @@ export function generateSizeRecipes(partial: Partial<SizeRecipeInputs> = {}): Ge
402
588
  boxVariants: control.boxVariants,
403
589
  typeVariants: control.typeVariants,
404
590
  iconVariants: control.iconVariants,
591
+ radii: control.radii,
405
592
  families,
406
593
  tiles: generateTileSizeLadder(resolveTileSizeInputs(inputs.tile ?? {})),
407
594
  };
@@ -23,9 +23,30 @@ export interface TextStateProps {
23
23
  fontSize?: string;
24
24
  }
25
25
 
26
- export interface ElevationStateProps {
26
+ export interface ElevationPaintProps {
27
+ shadowColor?: string;
28
+ shadowOffset?: { width: number; height: number };
29
+ shadowOpacity?: number;
30
+ shadowRadius?: number;
31
+ /** Web: raw boxShadow so paint does not depend on Tamagui's elevation variant. */
32
+ style?: { boxShadow: string };
33
+ }
34
+
35
+ export interface ElevationStateProps extends ElevationPaintProps {
27
36
  /** Size token on web ("$1"); numeric tokens.size value on native (RN-safe). */
28
37
  elevation?: string | number;
38
+ /** Dark-scheme surface tint (existing $color ramp). Light leaves this unset. */
39
+ backgroundColor?: string;
40
+ }
41
+
42
+ /**
43
+ * Complete elevation chrome to spread onto a raised frame (E-CONTROL wrappers,
44
+ * InputParts Box). Shadows + Android dp. No fill — filled inputs read
45
+ * `inputBackground`; overlays read `elevatedSurface.backgroundColor`.
46
+ */
47
+ export interface ElevationChromeProps extends ElevationPaintProps {
48
+ /** Android dp (2/4/8), not the Tamagui size token. */
49
+ elevation?: number;
29
50
  }
30
51
 
31
52
  // ── Disabled state recipe (LC-40 DISABLED-VISIBLE) ───────────────────────
@@ -102,8 +123,8 @@ export interface KnobRecipe<T> {
102
123
  * Corner smoothing carrier (Axiom 15 OPTICS): radius-bearing fragments gain
103
124
  * `className: "mp-corner-smooth"` when `cornerSmoothing: "smooth"` (web
104
125
  * only) — the mounted stylesheet in `cornerSmoothing.ts` resolves it to
105
- * `corner-shape: squircle`, a Chromium 139+ progressive enhancement other
106
- * engines ignore. Absent at `round` and on native.
126
+ * `corner-shape: squircle` (computed `superellipse(2)` on Chromium 152).
127
+ * Engines without the property drop the declaration; native is absent.
107
128
  */
108
129
  export interface CornerSmoothingCarrier {
109
130
  className?: string;
@@ -115,7 +136,36 @@ export interface KnobProps {
115
136
  borderColor: string;
116
137
  borderWidth: number;
117
138
  } & CornerSmoothingCarrier;
139
+ /**
140
+ * The radius for chrome NESTED INSIDE a radius-bearing frame — a tab
141
+ * indicator inside a tab bar, a code block inside a card, a toast's action
142
+ * strip. It resolves one or more steps SMALLER than `borderRadius` at every
143
+ * knob stop (medium: `$3` inside `$4`, full: `$5` inside `$12`), because a
144
+ * child corner that matched its parent's would read as a second frame.
145
+ *
146
+ * MPO-19 X12 (00 L9c): this was `borderRadiusOuter`, which named the
147
+ * opposite of what it resolves to — every one of its consumers uses it for
148
+ * inner chrome, so the name told each of them the wrong story. Renamed;
149
+ * `borderRadiusOuter` stays as a deprecated alias for one release.
150
+ */
151
+ borderRadiusNested: { borderRadius: string } & CornerSmoothingCarrier;
152
+ /** @deprecated MPO-19 X12 — use `borderRadiusNested`. Removed after one release. */
118
153
  borderRadiusOuter: { borderRadius: string } & CornerSmoothingCarrier;
154
+ /**
155
+ * A FILLED control's border. Same width as the outlined one, so switching
156
+ * fill style never moves a layout by 2px, but `transparent` — which is what
157
+ * Tamagui paints and what the fill style means: the surface is the
158
+ * affordance, so a ring around it is a second one.
159
+ *
160
+ * MPO-19 X3: filled controls spread the `borderRadius` fragment, whose
161
+ * `borderColor` is `$borderColor`, so with the default `borderWidth: medium`
162
+ * every default Button carried a visible 1px ring that stock Tamagui does
163
+ * not draw. The width still resolves through the `borderWidth` knob — set it
164
+ * to `none` and both fragments go to 0.
165
+ */
166
+ controlBorderFilled: { borderColor: string; borderWidth: number };
167
+ /** An OUTLINED control's border: the outline IS the affordance, so it is at least a hairline. */
168
+ controlBorderOutlined: { borderColor: string; borderWidth: number };
119
169
  /**
120
170
  * Axiom 1 child-clip cap for padded containers (Card/Panel/DialogContent):
121
171
  * the radius token while it fits, or the padding px once it would exceed
@@ -124,6 +174,19 @@ export interface KnobProps {
124
174
  containerRadius: { borderRadius: string | number } & CornerSmoothingCarrier;
125
175
  /** Size token on web ("$1"); numeric tokens.size value on native (RN-safe). */
126
176
  elevation: string | number | undefined;
177
+ /**
178
+ * Overlay elevation token (dialogs, popovers, Panel). Distinct from
179
+ * `elevation` so an overlay over a scrim is not the same $1 stop every
180
+ * Button carries. Spread via `elevatedSurface`; do not reuse `elevation`.
181
+ */
182
+ overlayElevation: string | number | undefined;
183
+ /**
184
+ * Complete shadow fragment for E-CONTROL / wrapper Views. Spread whole.
185
+ * Empty when `elevation=none`. Android `elevation` here is dp (2/4/8).
186
+ */
187
+ elevationChrome: ElevationChromeProps;
188
+ /** Resolved scheme the fragments were built for (LC-85). */
189
+ scheme: "light" | "dark";
127
190
  surface: { backgroundColor: string; borderColor: string; borderWidth: number };
128
191
  /** Content tier (Bento content card): padded frame, no border, no shadow. Radius carries the container cap. */
129
192
  cardSurface: {
@@ -138,7 +201,8 @@ export interface KnobProps {
138
201
  borderWidth: number;
139
202
  borderRadius: string;
140
203
  elevation: string | number | undefined;
141
- } & CornerSmoothingCarrier;
204
+ } & ElevationPaintProps &
205
+ CornerSmoothingCarrier;
142
206
  /** Feature/marketing tier (auth, hero cards): big radius + ultra-soft wide shadow. */
143
207
  featureSurface: {
144
208
  backgroundColor: string;