@multiplatform.one/theme 7.4.0 → 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
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  defaultKnobs,
4
4
  emptyStateKnobs,
5
+ FillStyle,
5
6
  Space,
6
7
  Size,
7
8
  Density,
@@ -39,9 +40,13 @@ function knobsWith(overrides: Partial<Knobs>): Knobs {
39
40
 
40
41
  const expectedKnobPropKeys: (keyof KnobProps)[] = [
41
42
  "borderRadius",
43
+ "borderRadiusNested",
42
44
  "borderRadiusOuter",
43
45
  "containerRadius",
44
46
  "elevation",
47
+ "overlayElevation",
48
+ "elevationChrome",
49
+ "scheme",
45
50
  "surface",
46
51
  "cardSurface",
47
52
  "elevatedSurface",
@@ -222,6 +227,13 @@ describe("resolveKnobs", () => {
222
227
  expect(resolveKnobs(knobsWith({ elevation: "small" })).knobProps.elevation).toBe("$1");
223
228
  });
224
229
 
230
+ it("overlay elevation at small is $2, not the control $1 token", () => {
231
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "small" }));
232
+ expect(knobProps.overlayElevation).toBe("$2");
233
+ expect(knobProps.elevatedSurface.elevation).toBe("$2");
234
+ expect(knobProps.elevatedSurface.elevation).not.toBe(knobProps.elevation);
235
+ });
236
+
225
237
  it('maps "medium" → "$2"', () => {
226
238
  expect(resolveKnobs(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe("$2");
227
239
  });
@@ -229,6 +241,41 @@ describe("resolveKnobs", () => {
229
241
  it('maps "large" → "$4"', () => {
230
242
  expect(resolveKnobs(knobsWith({ elevation: "large" })).knobProps.elevation).toBe("$4");
231
243
  });
244
+
245
+ it("light scheme keeps overlay fill at $background and paints a black shadow", () => {
246
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "light");
247
+ expect(knobProps.scheme).toBe("light");
248
+ expect(knobProps.elevatedSurface.backgroundColor).toBe("$background");
249
+ expect(knobProps.cardSurface.backgroundColor).toBe("$background");
250
+ expect(knobProps.inputBackground).toBe("$background");
251
+ expect(knobProps.elevatedSurface.shadowColor).toBe("#000");
252
+ expect(knobProps.elevatedSurface.shadowOpacity).toBe(0.15);
253
+ expect(knobProps.elevationChrome.elevation).toBe(8);
254
+ });
255
+
256
+ it("dark scheme tints overlay/card/input up the existing ramp (LC-85)", () => {
257
+ const none = resolveKnobs(knobsWith({ elevation: "none" }), undefined, "dark");
258
+ const small = resolveKnobs(knobsWith({ elevation: "small" }), undefined, "dark");
259
+ const large = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "dark");
260
+ expect(none.knobProps.elevatedSurface.backgroundColor).toBe("$background");
261
+ expect(small.knobProps.elevatedSurface.backgroundColor).toBe("$color2");
262
+ expect(large.knobProps.elevatedSurface.backgroundColor).toBe("$color4");
263
+ expect(large.knobProps.cardSurface.backgroundColor).toBe("$color4");
264
+ expect(large.knobProps.inputBackground).toBe("$color5");
265
+ expect(large.knobProps.elevatedSurface.shadowColor).toBe("$color12");
266
+ expect(large.knobProps.elevatedSurface.shadowOpacity).toBe(0.12);
267
+ expect(none.knobProps.elevationChrome).toEqual({});
268
+ });
269
+
270
+ it("dark outlined inputs stay transparent — tint is for filled surfaces", () => {
271
+ const { knobProps } = resolveKnobs(
272
+ knobsWith({ elevation: "large", fillStyle: "outlined" }),
273
+ undefined,
274
+ "dark",
275
+ );
276
+ expect(knobProps.inputBackground).toBe("transparent");
277
+ expect(knobProps.elevatedSurface.backgroundColor).toBe("$color4");
278
+ });
232
279
  });
233
280
 
234
281
  // ── elevation on NATIVE (SB-N-04) ──────────────────────────
@@ -251,10 +298,15 @@ describe("resolveKnobs", () => {
251
298
  knobsWith({ elevation: "small", hover: { elevation: "medium" } }),
252
299
  );
253
300
  expect(small.knobProps.elevation).toBe(20);
254
- expect(small.knobProps.elevatedSurface.elevation).toBe(20);
301
+ expect(small.knobProps.overlayElevation).toBe(28);
302
+ expect(small.knobProps.elevatedSurface.elevation).toBe(28);
303
+ expect(small.knobProps.elevatedSurface.elevation).not.toBe(small.knobProps.elevation);
255
304
  expect(small.elevation.hoverKnobProps?.elevation).toBe(28);
256
305
  expect(resolveNative(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe(28);
257
306
  expect(resolveNative(knobsWith({ elevation: "large" })).knobProps.elevation).toBe(44);
307
+ expect(
308
+ resolveNative(knobsWith({ elevation: "large" })).knobProps.elevationChrome.elevation,
309
+ ).toBe(8);
258
310
  } finally {
259
311
  vi.doUnmock("@multiplatform.one/platform");
260
312
  vi.resetModules();
@@ -424,7 +476,7 @@ describe("resolveKnobs", () => {
424
476
  it("cardSurface = bg + radius + panelPadding, no border/shadow", () => {
425
477
  const { knobProps } = resolveKnobs(defaultKnobs);
426
478
  expect(knobProps.cardSurface).toEqual({
427
- backgroundColor: "$color1",
479
+ backgroundColor: "$background",
428
480
  borderRadius: "$4",
429
481
  padding: "$4",
430
482
  });
@@ -443,13 +495,27 @@ describe("resolveKnobs", () => {
443
495
  expect(knobProps.cardSurface.borderRadius).toBe("$6");
444
496
  });
445
497
 
446
- it("elevatedSurface = surface + radius + elevation", () => {
498
+ it("elevatedSurface = surface + radius + overlay elevation + paint", () => {
447
499
  const { knobProps } = resolveKnobs(defaultKnobs);
448
- expect(knobProps.elevatedSurface).toEqual({
449
- ...knobProps.surface,
450
- borderRadius: knobProps.borderRadius.borderRadius,
451
- elevation: knobProps.elevation,
452
- });
500
+ expect(knobProps.elevatedSurface.elevation).toBe(knobProps.overlayElevation);
501
+ expect(knobProps.elevatedSurface.elevation).not.toBe(knobProps.elevation);
502
+ expect(knobProps.elevatedSurface.backgroundColor).toBe(knobProps.surface.backgroundColor);
503
+ expect(knobProps.elevatedSurface.borderColor).toBe(knobProps.surface.borderColor);
504
+ expect(knobProps.elevatedSurface.borderWidth).toBe(knobProps.surface.borderWidth);
505
+ expect(knobProps.elevatedSurface.borderRadius).toBe(knobProps.borderRadius.borderRadius);
506
+ });
507
+
508
+ it("overlay elevation token differs from the control elevation token", () => {
509
+ for (const elevation of ["small", "medium", "large"] as const) {
510
+ const { knobProps } = resolveKnobs(knobsWith({ elevation }));
511
+ expect(knobProps.overlayElevation, elevation).toBeDefined();
512
+ expect(knobProps.overlayElevation, elevation).not.toBe(knobProps.elevation);
513
+ expect(knobProps.elevatedSurface.elevation, elevation).toBe(knobProps.overlayElevation);
514
+ }
515
+ const none = resolveKnobs(knobsWith({ elevation: "none" }));
516
+ expect(none.knobProps.elevation).toBeUndefined();
517
+ expect(none.knobProps.overlayElevation).toBeUndefined();
518
+ expect(none.knobProps.elevatedSurface.elevation).toBeUndefined();
453
519
  });
454
520
 
455
521
  it("featureSurface uses big radius, $6 padding, and an ultra-soft shadow", () => {
@@ -543,7 +609,7 @@ describe("resolveKnobs", () => {
543
609
  describe("cornerSmoothing knob", () => {
544
610
  const radiusFragments = [
545
611
  "borderRadius",
546
- "borderRadiusOuter",
612
+ "borderRadiusNested",
547
613
  "containerRadius",
548
614
  "cardSurface",
549
615
  "elevatedSurface",
@@ -585,7 +651,7 @@ describe("resolveKnobs", () => {
585
651
  );
586
652
  // $0 corners have no arc — square world stays square (Axiom 3 NULL).
587
653
  expect(knobProps.borderRadius).not.toHaveProperty("className");
588
- expect(knobProps.borderRadiusOuter).not.toHaveProperty("className");
654
+ expect(knobProps.borderRadiusNested).not.toHaveProperty("className");
589
655
  expect(knobProps.containerRadius).not.toHaveProperty("className");
590
656
  expect(knobProps.cardSurface).not.toHaveProperty("className");
591
657
  expect(knobProps.elevatedSurface).not.toHaveProperty("className");
@@ -604,7 +670,7 @@ describe("resolveKnobs", () => {
604
670
  expect(knobProps.elevatedSurface).not.toHaveProperty("className");
605
671
  // Capped/outer fragments stay plain arcs (cap = padding px, outer ≤ $5)
606
672
  // and keep smoothing.
607
- expect(knobProps.borderRadiusOuter.className).toBe(cornerSmoothClassName);
673
+ expect(knobProps.borderRadiusNested.className).toBe(cornerSmoothClassName);
608
674
  expect(knobProps.containerRadius.className).toBe(cornerSmoothClassName);
609
675
  expect(knobProps.cardSurface.className).toBe(cornerSmoothClassName);
610
676
  expect(knobProps.featureSurface.className).toBe(cornerSmoothClassName);
@@ -681,6 +747,54 @@ describe("resolveKnobs", () => {
681
747
  const { knobProps } = resolveKnobs(knobsWith({ fillStyle: "outlined" }));
682
748
  expect(knobProps.outlined).toBe(true);
683
749
  });
750
+
751
+ it("union is filled | outlined only (R9 G9)", () => {
752
+ expect(Object.values(FillStyle)).toEqual(["filled", "outlined"]);
753
+ expect(Object.values(FillStyle)).toHaveLength(2);
754
+ });
755
+ });
756
+
757
+ describe("separator weight is not a knob (R9 G10)", () => {
758
+ it("Knobs has no separatorWeight channel", () => {
759
+ expect("separatorWeight" in defaultKnobs).toBe(false);
760
+ expect(Object.keys(defaultKnobs)).not.toContain("separatorWeight");
761
+ });
762
+ });
763
+
764
+ describe("fillStyle × borderWidth (LC-86 OUTLINED-HAIRLINE)", () => {
765
+ it("filled + none is chromeless (width 0)", () => {
766
+ const { knobProps } = resolveKnobs(knobsWith({ fillStyle: "filled", borderWidth: "none" }));
767
+ expect(knobProps.borderRadius.borderWidth).toBe(0);
768
+ expect(knobProps.surface.borderWidth).toBe(0);
769
+ expect(knobProps.elevatedSurface.borderWidth).toBe(0);
770
+ expect(knobProps.inputSurface.borderWidth).toBe(0);
771
+ });
772
+
773
+ it("outlined + none still draws the hairline (small stop, 0.5)", () => {
774
+ const { knobProps } = resolveKnobs(knobsWith({ fillStyle: "outlined", borderWidth: "none" }));
775
+ expect(knobProps.borderRadius.borderWidth).toBe(0.5);
776
+ expect(knobProps.surface.borderWidth).toBe(0.5);
777
+ expect(knobProps.elevatedSurface.borderWidth).toBe(0.5);
778
+ expect(knobProps.inputSurface.borderWidth).toBe(0.5);
779
+ });
780
+
781
+ it("outlined + small stays at the hairline, not a hidden 1px floor", () => {
782
+ const { knobProps } = resolveKnobs(
783
+ knobsWith({ fillStyle: "outlined", borderWidth: "small" }),
784
+ );
785
+ expect(knobProps.borderRadius.borderWidth).toBe(0.5);
786
+ });
787
+
788
+ it("outlined + medium/large keep the requested width", () => {
789
+ expect(
790
+ resolveKnobs(knobsWith({ fillStyle: "outlined", borderWidth: "medium" })).knobProps
791
+ .borderRadius.borderWidth,
792
+ ).toBe(1);
793
+ expect(
794
+ resolveKnobs(knobsWith({ fillStyle: "outlined", borderWidth: "large" })).knobProps
795
+ .borderRadius.borderWidth,
796
+ ).toBe(2);
797
+ });
684
798
  });
685
799
 
686
800
  // ── pointy ────────────────────────────────────────────────
@@ -1005,6 +1119,20 @@ describe("applyDensity", () => {
1005
1119
  expect(resolveKnobs(knobsWith({ density: "compact" })).knobProps.density).toBe("compact");
1006
1120
  });
1007
1121
 
1122
+ it("compact and comfortable differ in padX while height stays 44 (LC-76)", () => {
1123
+ const comfortable = resolveKnobs(knobsWith({ density: "comfortable" }));
1124
+ const compact = resolveKnobs(knobsWith({ density: "compact" }));
1125
+ expect(comfortable.knobProps.control.height).toBe(44);
1126
+ expect(compact.knobProps.control.height).toBe(44);
1127
+ expect(comfortable.knobProps.sizeToken).toBe("$4");
1128
+ expect(compact.knobProps.sizeToken).toBe("$4");
1129
+ expect(compact.knobProps.control.paddingHorizontal).not.toBe(
1130
+ comfortable.knobProps.control.paddingHorizontal,
1131
+ );
1132
+ expect(comfortable.knobProps.control.paddingHorizontal).toBe(18);
1133
+ expect(compact.knobProps.control.paddingHorizontal).toBe(16);
1134
+ });
1135
+
1008
1136
  it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
1009
1137
  const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
1010
1138
  const compact = resolveKnobs(
@@ -9,11 +9,13 @@ import {
9
9
  STATE_LAYER_PRESS,
10
10
  withDefaultStateLayer,
11
11
  } from "./focusState";
12
- import type { CornerSmoothing, Knobs, PageTitleScale, StateKnobs } from "./knobs";
12
+ import type { CornerSmoothing, Elevation, Knobs, PageTitleScale, StateKnobs } from "./knobs";
13
13
  import { pressTargetHitSlop } from "./layoutTokens";
14
14
  import type {
15
15
  ControlStateProps,
16
16
  DisabledRecipe,
17
+ ElevationChromeProps,
18
+ ElevationPaintProps,
17
19
  ElevationStateProps,
18
20
  KnobProps,
19
21
  KnobPropsOverride,
@@ -64,7 +66,7 @@ export function applyDensity(knobs: Knobs, compactOverride?: boolean): Knobs {
64
66
 
65
67
  // ── Knob-to-token mapping tables ──────────────────────────────
66
68
 
67
- const borderRadiusMap = {
69
+ export const borderRadiusMap = {
68
70
  none: "$0",
69
71
  small: "$2",
70
72
  medium: "$4",
@@ -72,7 +74,12 @@ const borderRadiusMap = {
72
74
  full: "$12",
73
75
  } as const;
74
76
 
75
- const outerRadiusMap = {
77
+ // Radius for chrome nested INSIDE a radius-bearing frame. Smaller than
78
+ // `borderRadiusMap` at every stop that has room to be — a child corner equal
79
+ // to its parent's reads as a second frame rather than as part of one.
80
+ // MPO-19 X12: was `outerRadiusMap`, feeding a fragment called
81
+ // `borderRadiusOuter` that every consumer used for inner chrome.
82
+ const nestedRadiusMap = {
76
83
  none: "$0",
77
84
  small: "$2",
78
85
  medium: "$3",
@@ -243,10 +250,121 @@ const fontWeightMap = {
243
250
  // be cast to java.lang.Double" (SB-N-04). The numbers are the exact
244
251
  // tokens.size values Tamagui resolves those tokens to ($1=20, $2=28, $4=44),
245
252
  // so Tamagui consumers render identical shadows either way.
246
- const elevationMap: Record<string, string | number | undefined> = isWeb
253
+ //
254
+ // Control elevation ($1 at the default small stop) is the in-page Button
255
+ // token. Overlay elevation is a DISTINCT token one step above that stop so
256
+ // a dialog over a scrim and a Button on the page do not read as the same
257
+ // distance off the surface (DG-OVL-01 / E-OVERLAY). elevatedSurface binds
258
+ // the overlay token — not the control one.
259
+ export const elevationMap: Record<string, string | number | undefined> = isWeb
247
260
  ? { none: undefined, small: "$1", medium: "$2", large: "$4" }
248
261
  : { none: undefined, small: 20, medium: 28, large: 44 };
249
262
 
263
+ export const overlayElevationMap: Record<string, string | number | undefined> = isWeb
264
+ ? { none: undefined, small: "$2", medium: "$3", large: "$5" }
265
+ : { none: undefined, small: 28, medium: 36, large: 52 };
266
+
267
+ /** Scheme the resolver was invoked with. Defaults light so unit tests stay stable. */
268
+ export type ColorScheme = "light" | "dark";
269
+
270
+ // Existing 12-step ramp (colorRules groups). Do not invent overlay tokens:
271
+ // dark elevation is a lighter $color step. Overlay/card rest on $color1;
272
+ // filled inputs rest on $color2 and step from there.
273
+ const elevationTintOverlay: Record<Elevation, string> = {
274
+ none: "$color1",
275
+ small: "$color2",
276
+ medium: "$color3",
277
+ large: "$color4",
278
+ };
279
+
280
+ const elevationTintInput: Record<Elevation, string> = {
281
+ none: "$color2",
282
+ small: "$color3",
283
+ medium: "$color4",
284
+ large: "$color5",
285
+ };
286
+
287
+ // Axiom 14 light source: y-offset:blur = 0.5. Metrics match the maps
288
+ // InputParts / getElevationWrapperProps already shipped.
289
+ const elevationShadowMetrics: Record<
290
+ "$1" | "$2" | "$4",
291
+ { offsetY: number; radius: number; android: number }
292
+ > = {
293
+ $1: { offsetY: 1, radius: 2, android: 2 },
294
+ $2: { offsetY: 3, radius: 6, android: 4 },
295
+ $4: { offsetY: 10, radius: 20, android: 8 },
296
+ };
297
+
298
+ const elevationKeyAlias: Record<string, "$1" | "$2" | "$4"> = {
299
+ $1: "$1",
300
+ $2: "$2",
301
+ $3: "$2",
302
+ $4: "$4",
303
+ $5: "$4",
304
+ "20": "$1",
305
+ "28": "$2",
306
+ "36": "$2",
307
+ "44": "$4",
308
+ "52": "$4",
309
+ };
310
+
311
+ const webShadowLight: Record<"$1" | "$2" | "$4", string> = {
312
+ $1: "0 1px 2px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08)",
313
+ $2: "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
314
+ $4: "0 10px 20px rgba(0,0,0,0.15), 0 3px 6px rgba(0,0,0,0.10)",
315
+ };
316
+
317
+ const webShadowDark: Record<"$1" | "$2" | "$4", string> = {
318
+ $1: "0 1px 2px rgba(255,255,255,0.08), 0 0 1px rgba(255,255,255,0.06)",
319
+ $2: "0 3px 6px rgba(255,255,255,0.10), 0 2px 4px rgba(255,255,255,0.06)",
320
+ $4: "0 10px 20px rgba(255,255,255,0.12), 0 3px 6px rgba(255,255,255,0.08)",
321
+ };
322
+
323
+ function elevationTokenKey(token: string | number | undefined): "$1" | "$2" | "$4" | undefined {
324
+ if (token == null) return undefined;
325
+ return elevationKeyAlias[String(token)];
326
+ }
327
+
328
+ function elevationPaint(
329
+ token: string | number | undefined,
330
+ scheme: ColorScheme,
331
+ ): ElevationPaintProps {
332
+ const key = elevationTokenKey(token);
333
+ if (!key) return {};
334
+ const metrics = elevationShadowMetrics[key];
335
+ const dark = scheme === "dark";
336
+ const paint: ElevationPaintProps = {
337
+ shadowColor: dark ? "$color12" : "#000",
338
+ shadowOffset: { width: 0, height: metrics.offsetY },
339
+ shadowOpacity: dark
340
+ ? key === "$4"
341
+ ? 0.12
342
+ : key === "$2"
343
+ ? 0.1
344
+ : 0.08
345
+ : key === "$1"
346
+ ? 0.12
347
+ : 0.15,
348
+ shadowRadius: metrics.radius,
349
+ };
350
+ if (isWeb) {
351
+ paint.style = { boxShadow: dark ? webShadowDark[key] : webShadowLight[key] };
352
+ }
353
+ return paint;
354
+ }
355
+
356
+ function elevationChromeFor(
357
+ token: string | number | undefined,
358
+ scheme: ColorScheme,
359
+ ): ElevationChromeProps {
360
+ const key = elevationTokenKey(token);
361
+ if (!key) return {};
362
+ return {
363
+ ...elevationPaint(token, scheme),
364
+ elevation: elevationShadowMetrics[key].android,
365
+ };
366
+ }
367
+
250
368
  // Legibility floor: body/label text must never land below ~4.5:1 against its
251
369
  // surface. $color11 is the lowest ramp step that clears AA on color1/2
252
370
  // surfaces, so "low" clamps there ($color9 measured 1.29-3.43:1 — SB-R-01).
@@ -317,7 +435,10 @@ function resolveDisabled(disabledStyle: Knobs["disabledStyle"]): DisabledRecipe
317
435
 
318
436
  // ── State style resolver ──────────────────────────────────────
319
437
 
320
- function resolveStateKnobs(state: StateKnobs): {
438
+ function resolveStateKnobs(
439
+ state: StateKnobs,
440
+ scheme: ColorScheme,
441
+ ): {
321
442
  control: ControlStateProps;
322
443
  text: TextStateProps;
323
444
  elevation: ElevationStateProps;
@@ -339,7 +460,12 @@ function resolveStateKnobs(state: StateKnobs): {
339
460
  control.padding = gapMap[state.space];
340
461
  }
341
462
  if (state.elevation !== undefined) {
342
- elevation.elevation = elevationMap[state.elevation];
463
+ const token = elevationMap[state.elevation];
464
+ elevation.elevation = token;
465
+ Object.assign(elevation, elevationPaint(token, scheme));
466
+ if (scheme === "dark" && state.elevation !== "none") {
467
+ elevation.backgroundColor = elevationTintOverlay[state.elevation];
468
+ }
343
469
  }
344
470
  if (state.textAccent !== undefined) {
345
471
  text.color = textAccentColorMap[state.textAccent];
@@ -358,15 +484,19 @@ function resolveStateKnobs(state: StateKnobs): {
358
484
 
359
485
  // ── Resolver ──────────────────────────────────────────────────
360
486
 
361
- export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): ResolvedKnobs {
487
+ export function resolveKnobs(
488
+ knobs: Knobs,
489
+ override?: KnobPropsOverride,
490
+ scheme: ColorScheme = "light",
491
+ ): ResolvedKnobs {
362
492
  const radiusToken = borderRadiusMap[knobs.borderRadius];
363
- const outerRadiusToken = outerRadiusMap[knobs.borderRadius];
364
- const borderWidthValue = borderWidthMap[knobs.borderWidth];
493
+ const nestedRadiusToken = nestedRadiusMap[knobs.borderRadius];
365
494
  const sizeToken = sizeTokenMap[knobs.size];
366
495
  const nestedPx = nestedControlPxMap[knobs.size];
367
496
  const nestedHitSlop = pressTargetHitSlop(nestedPx);
368
497
  const sizeRecipe = sizeRecipeForToken(sizeToken, {
369
498
  touch: isTouchable || isWebTouchable,
499
+ family: knobs.density === "compact" ? "controlCompact" : "control",
370
500
  });
371
501
  const paddingToken = panelPaddingMap[knobs.space];
372
502
  const gapToken = gapMap[knobs.space];
@@ -393,18 +523,38 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
393
523
  const containerSmoothing = knobs.borderRadius === "none" ? undefined : smoothing;
394
524
 
395
525
  const outlined = knobs.fillStyle === "outlined";
526
+ // LC-86 OUTLINED-HAIRLINE: outlined implies a minimum border. none on an
527
+ // outlined control still draws the enumerated `small` stop (0.5). Chromeless
528
+ // is filled + none. Components must not add a second local floor.
529
+ const borderWidthValue =
530
+ outlined && knobs.borderWidth === "none"
531
+ ? borderWidthMap.small
532
+ : borderWidthMap[knobs.borderWidth];
396
533
  const elevationToken = elevationMap[knobs.elevation];
534
+ const overlayElevationToken = overlayElevationMap[knobs.elevation];
535
+ const overlayPaintProps = elevationPaint(overlayElevationToken, scheme);
536
+ const elevationChrome = elevationChromeFor(elevationToken, scheme);
537
+ const dark = scheme === "dark";
538
+ // MPO-19 X5: `$background` (not `$color1`) so surface1/2/3 sub-themes move
539
+ // the fill. Dark + elevation still steps the filled rest fill up the ramp
540
+ // (LC-85).
541
+ const overlayFill =
542
+ dark && knobs.elevation !== "none" ? elevationTintOverlay[knobs.elevation] : "$background";
397
543
  const textAccentColor = textAccentColorMap[knobs.textAccent] ?? "$color";
398
- // One input surface (DF-19 / Axiom 5 ONE SOURCE): the knob resolves to the
399
- // same tier forms' InputContainer uses (formInputColors.background.base =
400
- // $color2), so Timeline's composer, RichEditor, and form fields agree on
401
- // what an input surface is. $color6 forked a darker second voice.
402
- const inputBackground = outlined ? "transparent" : "$color2";
403
-
404
- const hover = resolveStateKnobs(knobs.hover);
405
- const press = resolveStateKnobs(knobs.press);
406
- const focus = resolveStateKnobs(knobs.focus);
407
- const focusVisible = resolveStateKnobs(knobs.focusVisible);
544
+ // One input surface (DF-19 / Axiom 5 ONE SOURCE): Timeline's composer,
545
+ // RichEditor and form fields agree on what an input surface is.
546
+ // MPO-19 X6: `$background` not `$color2` so a surface sub-theme can move it.
547
+ // Dark + elevation steps the filled rest fill up the existing ramp (LC-85).
548
+ const inputBackground = outlined
549
+ ? "transparent"
550
+ : dark
551
+ ? elevationTintInput[knobs.elevation]
552
+ : "$background";
553
+
554
+ const hover = resolveStateKnobs(knobs.hover, scheme);
555
+ const press = resolveStateKnobs(knobs.press, scheme);
556
+ const focus = resolveStateKnobs(knobs.focus, scheme);
557
+ const focusVisible = resolveStateKnobs(knobs.focusVisible, scheme);
408
558
 
409
559
  const nonEmpty = <T extends object>(obj: T): T | undefined =>
410
560
  Object.keys(obj).length > 0 ? obj : undefined;
@@ -430,6 +580,8 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
430
580
  focusVisibleKnobProps: nonEmpty(focusVisible.elevation),
431
581
  };
432
582
 
583
+ const nestedRadius = { borderRadius: nestedRadiusToken, ...containerSmoothing };
584
+
433
585
  const knobProps: KnobProps = {
434
586
  borderRadius: {
435
587
  borderRadius: radiusToken,
@@ -437,33 +589,46 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
437
589
  borderWidth: borderWidthValue,
438
590
  ...scaleSmoothing,
439
591
  },
440
- borderRadiusOuter: { borderRadius: outerRadiusToken, ...containerSmoothing },
592
+ borderRadiusNested: nestedRadius,
593
+ // MPO-19 X12: the old name for the same fragment, kept for one release so
594
+ // an out-of-tree consumer does not break on the rename. Same object, so
595
+ // the two cannot drift while both exist.
596
+ borderRadiusOuter: nestedRadius,
597
+ controlBorderFilled: { borderColor: "transparent", borderWidth: borderWidthValue },
598
+ controlBorderOutlined: {
599
+ borderColor: "$borderColor",
600
+ borderWidth: Math.max(borderWidthValue, 1),
601
+ },
441
602
  // Axiom 1 child-clip cap: padded containers (Card/Panel/DialogContent)
442
603
  // spread this after their radius-bearing fragment. Non-container parts
443
604
  // keep the uncapped `borderRadius` fragment.
444
605
  containerRadius: { borderRadius: containerRadiusValue, ...containerSmoothing },
445
606
  elevation: elevationToken,
607
+ overlayElevation: overlayElevationToken,
608
+ elevationChrome,
609
+ scheme,
446
610
  surface: {
447
- backgroundColor: "$color1",
611
+ backgroundColor: "$background",
448
612
  borderColor: "$borderColor",
449
613
  borderWidth: borderWidthValue,
450
614
  },
451
615
  cardSurface: {
452
- backgroundColor: "$color1",
616
+ backgroundColor: overlayFill,
453
617
  borderRadius: containerRadiusValue,
454
618
  padding: paddingToken,
455
619
  ...containerSmoothing,
456
620
  },
457
621
  elevatedSurface: {
458
- backgroundColor: "$color1",
622
+ backgroundColor: overlayFill,
459
623
  borderColor: "$borderColor",
460
624
  borderWidth: borderWidthValue,
461
625
  borderRadius: radiusToken,
462
- elevation: elevationToken,
626
+ elevation: overlayElevationToken,
627
+ ...overlayPaintProps,
463
628
  ...scaleSmoothing,
464
629
  },
465
630
  featureSurface: {
466
- backgroundColor: "$color1",
631
+ backgroundColor: "$background",
467
632
  borderRadius: "$10",
468
633
  padding: "$6",
469
634
  // Bento marketing/auth card shadow: ~0px 9px 12px rgba(0,0,0,0.02) —
@@ -0,0 +1,84 @@
1
+ /**
2
+ * MPO-46 / MPO-19 X1/X2 — the size ladder is the measured Tamagui v5 table
3
+ * (R6: real TABLE, no escape). padX / label / icon / radius at $2/$3/$4/$6
4
+ * come from tamagui-rendered-reference.md §6.1; Input padX is §6.2
5
+ * (one space half-step below the button). gap == radius at every size
6
+ * and every borderRadius knob stop.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import {
11
+ SIZE_RECIPE_RADII,
12
+ SIZE_RECIPE_RADIUS_STOPS,
13
+ TAMAGUI_CONTROL_SIZE_TABLE,
14
+ TAMAGUI_INPUT_PADX,
15
+ applyRadiusStop,
16
+ generateSizeRecipes,
17
+ recipeFamilies,
18
+ sizeRecipeForToken,
19
+ sizeRecipes,
20
+ type SizeRecipeRadiusStop,
21
+ } from "./sizeRecipes";
22
+
23
+ const MEASURED = {
24
+ $2: { paddingHorizontal: 7, fontSize: 12, iconSize: 12, radius: 5 },
25
+ $3: { paddingHorizontal: 13, fontSize: 13, iconSize: 14, radius: 7 },
26
+ $4: { paddingHorizontal: 18, fontSize: 14, iconSize: 16, radius: 9 },
27
+ $6: { paddingHorizontal: 32, fontSize: 18, iconSize: 20, radius: 16 },
28
+ } as const;
29
+
30
+ describe("MPO-46 size ladder — measured table, not a ratio (R6)", () => {
31
+ it("padX, label, icon and radius at $2/$3/$4/$6 match the Tamagui v5 reference", () => {
32
+ for (const token of ["$2", "$3", "$4", "$6"] as const) {
33
+ const recipe = sizeRecipeForToken(token);
34
+ const row = MEASURED[token];
35
+ expect(recipe.paddingHorizontal, `${token} padX`).toBe(row.paddingHorizontal);
36
+ expect(recipe.fontSize, `${token} label`).toBe(row.fontSize);
37
+ expect(recipe.iconSize, `${token} icon`).toBe(row.iconSize);
38
+ expect(SIZE_RECIPE_RADII[token], `${token} radius`).toBe(row.radius);
39
+ expect(TAMAGUI_CONTROL_SIZE_TABLE[token]).toEqual(row);
40
+ }
41
+ });
42
+
43
+ it("does not ship the linear-ratio $2 defects (padX 11, label 9, flat radius 9)", () => {
44
+ const two = sizeRecipeForToken("$2");
45
+ expect(two.paddingHorizontal).not.toBe(11);
46
+ expect(two.fontSize).not.toBe(9);
47
+ expect(SIZE_RECIPE_RADII.$2).not.toBe(9);
48
+ expect(SIZE_RECIPE_RADII.$2).not.toBe(SIZE_RECIPE_RADII.$6);
49
+ });
50
+
51
+ it("Button / Select trigger / ListItem $4 is 44 / padX 18 / radius 9; Input padX is 16", () => {
52
+ const button = sizeRecipes.$4;
53
+ expect(button.height).toBe(44);
54
+ expect(button.paddingHorizontal).toBe(18);
55
+ expect(SIZE_RECIPE_RADII.$4).toBe(9);
56
+ expect(TAMAGUI_INPUT_PADX.$4).toBe(16);
57
+ expect(recipeFamilies.controlCompact.recipes.$4.paddingHorizontal).toBe(16);
58
+ expect(recipeFamilies.controlCompact.recipes.$4.height).toBe(44);
59
+ expect(recipeFamilies.controlCompact.radii.$4).toBe(9);
60
+ });
61
+ });
62
+
63
+ describe("MPO-46 gap == radius", () => {
64
+ it("holds at every size step", () => {
65
+ const generated = generateSizeRecipes();
66
+ for (const token of Object.keys(generated.recipes)) {
67
+ expect(generated.recipes[token].gap, `control ${token}`).toBe(generated.radii[token]);
68
+ expect(generated.families.controlCompact.recipes[token].gap, `compact ${token}`).toBe(
69
+ generated.families.controlCompact.radii[token],
70
+ );
71
+ }
72
+ });
73
+
74
+ it("holds at every borderRadius knob stop", () => {
75
+ const stops = Object.keys(SIZE_RECIPE_RADIUS_STOPS) as SizeRecipeRadiusStop[];
76
+ for (const token of ["$2", "$3", "$4", "$5", "$6"] as const) {
77
+ const recipe = sizeRecipeForToken(token);
78
+ for (const stop of stops) {
79
+ const next = applyRadiusStop(recipe, stop);
80
+ expect(next.gap, `${token} @ ${stop}`).toBe(SIZE_RECIPE_RADIUS_STOPS[stop]);
81
+ }
82
+ }
83
+ });
84
+ });