@multiplatform.one/theme 7.4.1 → 7.6.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 (119) hide show
  1. package/README.md +2 -1
  2. package/package.json +5 -5
  3. package/src/audit/index.ts +19 -0
  4. package/src/audit/themeMatrix.spec.ts +274 -0
  5. package/src/audit/themeMatrix.ts +630 -0
  6. package/src/figma/figmaTokens.spec.ts +25 -21
  7. package/src/figma/figmaTokens.ts +2 -2
  8. package/src/font.ts +37 -25
  9. package/src/index.ts +12 -0
  10. package/src/keyboardFocusRing.ts +12 -6
  11. package/src/theme/FontKnobStyles.tsx +14 -31
  12. package/src/theme/Tint.tsx +3 -2
  13. package/src/theme/animations/css.ts +14 -1
  14. package/src/theme/colorRules.spec.ts +306 -2
  15. package/src/theme/colorRules.ts +118 -0
  16. package/src/theme/cornerSmoothing.native.ts +39 -2
  17. package/src/theme/cornerSmoothing.spec.ts +270 -3
  18. package/src/theme/cornerSmoothing.ts +147 -10
  19. package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
  20. package/src/theme/createDefaultThemeConfig.ts +27 -8
  21. package/src/theme/defaults/builderOptions.ts +23 -10
  22. package/src/theme/defaults/categoryFonts.ts +24 -0
  23. package/src/theme/defaults/fonts.spec.ts +163 -10
  24. package/src/theme/defaults/fonts.theme.spec.tsx +139 -0
  25. package/src/theme/defaults/fonts.ts +214 -50
  26. package/src/theme/defaults/index.ts +1 -0
  27. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
  28. package/src/theme/focusState.spec.ts +119 -1
  29. package/src/theme/focusState.ts +32 -7
  30. package/src/theme/fontCategoryMetrics.ts +57 -0
  31. package/src/theme/fontCategoryStacks.ts +37 -0
  32. package/src/theme/fontKnobStyles.spec.ts +15 -2
  33. package/src/theme/glyphPaint.spec.ts +26 -0
  34. package/src/theme/glyphPaint.ts +46 -0
  35. package/src/theme/index.ts +4 -0
  36. package/src/theme/knobs.ts +18 -2
  37. package/src/theme/layoutTokens.spec.ts +3 -1
  38. package/src/theme/layoutTokens.ts +47 -4
  39. package/src/theme/mpo13Proof.spec.ts +3 -3
  40. package/src/theme/mpo19Proof.spec.ts +285 -0
  41. package/src/theme/presets.ts +1 -1
  42. package/src/theme/radiusClass.spec.ts +394 -0
  43. package/src/theme/radiusClass.ts +203 -4
  44. package/src/theme/radiusClassTable.generated.ts +121 -0
  45. package/src/theme/recipeInputs.ts +223 -36
  46. package/src/theme/recipes.ts +68 -4
  47. package/src/theme/resolveKnobs.spec.ts +155 -13
  48. package/src/theme/resolveKnobs.ts +193 -27
  49. package/src/theme/sizeLadder.spec.ts +84 -0
  50. package/src/theme/sizeRecipes.spec.ts +19 -6
  51. package/src/theme/sizeRecipes.ts +43 -13
  52. package/src/theme/theme.native.tsx +2 -0
  53. package/src/theme/theme.provider.spec.ts +9 -0
  54. package/src/theme/theme.tsx +6 -3
  55. package/src/theme/tint.spec.tsx +36 -35
  56. package/src/theme/useResolvedKnobs.ts +6 -3
  57. package/types/audit/index.d.ts +2 -0
  58. package/types/audit/index.d.ts.map +1 -1
  59. package/types/audit/themeMatrix.d.ts +172 -0
  60. package/types/audit/themeMatrix.d.ts.map +1 -0
  61. package/types/figma/figmaTokens.d.ts +1 -1
  62. package/types/figma/figmaTokens.d.ts.map +1 -1
  63. package/types/font.d.ts.map +1 -1
  64. package/types/index.d.ts +2 -0
  65. package/types/index.d.ts.map +1 -1
  66. package/types/keyboardFocusRing.d.ts +11 -5
  67. package/types/keyboardFocusRing.d.ts.map +1 -1
  68. package/types/theme/FontKnobStyles.d.ts +8 -12
  69. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  70. package/types/theme/Tint.d.ts +2 -1
  71. package/types/theme/Tint.d.ts.map +1 -1
  72. package/types/theme/animations/css.d.ts +15 -2
  73. package/types/theme/animations/css.d.ts.map +1 -1
  74. package/types/theme/colorRules.d.ts +59 -1
  75. package/types/theme/colorRules.d.ts.map +1 -1
  76. package/types/theme/cornerSmoothing.d.ts +42 -20
  77. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  78. package/types/theme/cornerSmoothing.native.d.ts +22 -2
  79. package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
  80. package/types/theme/createDefaultThemeConfig.d.ts +21 -3
  81. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  82. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  83. package/types/theme/defaults/categoryFonts.d.ts +23 -0
  84. package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
  85. package/types/theme/defaults/fonts.d.ts +140 -2
  86. package/types/theme/defaults/fonts.d.ts.map +1 -1
  87. package/types/theme/defaults/index.d.ts +1 -0
  88. package/types/theme/defaults/index.d.ts.map +1 -1
  89. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  90. package/types/theme/focusState.d.ts +32 -6
  91. package/types/theme/focusState.d.ts.map +1 -1
  92. package/types/theme/fontCategoryMetrics.d.ts +36 -0
  93. package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
  94. package/types/theme/fontCategoryStacks.d.ts +24 -0
  95. package/types/theme/fontCategoryStacks.d.ts.map +1 -0
  96. package/types/theme/glyphPaint.d.ts +16 -0
  97. package/types/theme/glyphPaint.d.ts.map +1 -0
  98. package/types/theme/index.d.ts +4 -0
  99. package/types/theme/index.d.ts.map +1 -1
  100. package/types/theme/knobs.d.ts +10 -2
  101. package/types/theme/knobs.d.ts.map +1 -1
  102. package/types/theme/layoutTokens.d.ts +46 -4
  103. package/types/theme/layoutTokens.d.ts.map +1 -1
  104. package/types/theme/radiusClass.d.ts +96 -4
  105. package/types/theme/radiusClass.d.ts.map +1 -1
  106. package/types/theme/radiusClassTable.generated.d.ts +146 -0
  107. package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
  108. package/types/theme/recipeInputs.d.ts +85 -14
  109. package/types/theme/recipeInputs.d.ts.map +1 -1
  110. package/types/theme/recipes.d.ts +78 -4
  111. package/types/theme/recipes.d.ts.map +1 -1
  112. package/types/theme/resolveKnobs.d.ts +12 -1
  113. package/types/theme/resolveKnobs.d.ts.map +1 -1
  114. package/types/theme/sizeRecipes.d.ts +27 -14
  115. package/types/theme/sizeRecipes.d.ts.map +1 -1
  116. package/types/theme/theme.d.ts.map +1 -1
  117. package/types/theme/theme.native.d.ts.map +1 -1
  118. package/types/theme/useResolvedKnobs.d.ts +2 -2
  119. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
@@ -1,7 +1,9 @@
1
+ import { tokens } from "@tamagui/themes";
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import {
3
4
  defaultKnobs,
4
5
  emptyStateKnobs,
6
+ FillStyle,
5
7
  Space,
6
8
  Size,
7
9
  Density,
@@ -39,9 +41,13 @@ function knobsWith(overrides: Partial<Knobs>): Knobs {
39
41
 
40
42
  const expectedKnobPropKeys: (keyof KnobProps)[] = [
41
43
  "borderRadius",
44
+ "borderRadiusNested",
42
45
  "borderRadiusOuter",
43
46
  "containerRadius",
44
47
  "elevation",
48
+ "overlayElevation",
49
+ "elevationChrome",
50
+ "scheme",
45
51
  "surface",
46
52
  "cardSurface",
47
53
  "elevatedSurface",
@@ -222,6 +228,13 @@ describe("resolveKnobs", () => {
222
228
  expect(resolveKnobs(knobsWith({ elevation: "small" })).knobProps.elevation).toBe("$1");
223
229
  });
224
230
 
231
+ it("overlay elevation at small is $2, not the control $1 token", () => {
232
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "small" }));
233
+ expect(knobProps.overlayElevation).toBe("$2");
234
+ expect(knobProps.elevatedSurface.elevation).toBe("$2");
235
+ expect(knobProps.elevatedSurface.elevation).not.toBe(knobProps.elevation);
236
+ });
237
+
225
238
  it('maps "medium" → "$2"', () => {
226
239
  expect(resolveKnobs(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe("$2");
227
240
  });
@@ -229,6 +242,41 @@ describe("resolveKnobs", () => {
229
242
  it('maps "large" → "$4"', () => {
230
243
  expect(resolveKnobs(knobsWith({ elevation: "large" })).knobProps.elevation).toBe("$4");
231
244
  });
245
+
246
+ it("light scheme keeps overlay fill at $background and paints a black shadow", () => {
247
+ const { knobProps } = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "light");
248
+ expect(knobProps.scheme).toBe("light");
249
+ expect(knobProps.elevatedSurface.backgroundColor).toBe("$background");
250
+ expect(knobProps.cardSurface.backgroundColor).toBe("$background");
251
+ expect(knobProps.inputBackground).toBe("$background");
252
+ expect(knobProps.elevatedSurface.shadowColor).toBe("#000");
253
+ expect(knobProps.elevatedSurface.shadowOpacity).toBe(0.15);
254
+ expect(knobProps.elevationChrome.elevation).toBe(8);
255
+ });
256
+
257
+ it("dark scheme tints overlay/card/input up the existing ramp (LC-85)", () => {
258
+ const none = resolveKnobs(knobsWith({ elevation: "none" }), undefined, "dark");
259
+ const small = resolveKnobs(knobsWith({ elevation: "small" }), undefined, "dark");
260
+ const large = resolveKnobs(knobsWith({ elevation: "large" }), undefined, "dark");
261
+ expect(none.knobProps.elevatedSurface.backgroundColor).toBe("$background");
262
+ expect(small.knobProps.elevatedSurface.backgroundColor).toBe("$color2");
263
+ expect(large.knobProps.elevatedSurface.backgroundColor).toBe("$color4");
264
+ expect(large.knobProps.cardSurface.backgroundColor).toBe("$color4");
265
+ expect(large.knobProps.inputBackground).toBe("$color5");
266
+ expect(large.knobProps.elevatedSurface.shadowColor).toBe("$color12");
267
+ expect(large.knobProps.elevatedSurface.shadowOpacity).toBe(0.12);
268
+ expect(none.knobProps.elevationChrome).toEqual({});
269
+ });
270
+
271
+ it("dark outlined inputs stay transparent — tint is for filled surfaces", () => {
272
+ const { knobProps } = resolveKnobs(
273
+ knobsWith({ elevation: "large", fillStyle: "outlined" }),
274
+ undefined,
275
+ "dark",
276
+ );
277
+ expect(knobProps.inputBackground).toBe("transparent");
278
+ expect(knobProps.elevatedSurface.backgroundColor).toBe("$color4");
279
+ });
232
280
  });
233
281
 
234
282
  // ── elevation on NATIVE (SB-N-04) ──────────────────────────
@@ -251,10 +299,15 @@ describe("resolveKnobs", () => {
251
299
  knobsWith({ elevation: "small", hover: { elevation: "medium" } }),
252
300
  );
253
301
  expect(small.knobProps.elevation).toBe(20);
254
- expect(small.knobProps.elevatedSurface.elevation).toBe(20);
302
+ expect(small.knobProps.overlayElevation).toBe(28);
303
+ expect(small.knobProps.elevatedSurface.elevation).toBe(28);
304
+ expect(small.knobProps.elevatedSurface.elevation).not.toBe(small.knobProps.elevation);
255
305
  expect(small.elevation.hoverKnobProps?.elevation).toBe(28);
256
306
  expect(resolveNative(knobsWith({ elevation: "medium" })).knobProps.elevation).toBe(28);
257
307
  expect(resolveNative(knobsWith({ elevation: "large" })).knobProps.elevation).toBe(44);
308
+ expect(
309
+ resolveNative(knobsWith({ elevation: "large" })).knobProps.elevationChrome.elevation,
310
+ ).toBe(8);
258
311
  } finally {
259
312
  vi.doUnmock("@multiplatform.one/platform");
260
313
  vi.resetModules();
@@ -424,7 +477,7 @@ describe("resolveKnobs", () => {
424
477
  it("cardSurface = bg + radius + panelPadding, no border/shadow", () => {
425
478
  const { knobProps } = resolveKnobs(defaultKnobs);
426
479
  expect(knobProps.cardSurface).toEqual({
427
- backgroundColor: "$color1",
480
+ backgroundColor: "$background",
428
481
  borderRadius: "$4",
429
482
  padding: "$4",
430
483
  });
@@ -443,13 +496,27 @@ describe("resolveKnobs", () => {
443
496
  expect(knobProps.cardSurface.borderRadius).toBe("$6");
444
497
  });
445
498
 
446
- it("elevatedSurface = surface + radius + elevation", () => {
499
+ it("elevatedSurface = surface + radius + overlay elevation + paint", () => {
447
500
  const { knobProps } = resolveKnobs(defaultKnobs);
448
- expect(knobProps.elevatedSurface).toEqual({
449
- ...knobProps.surface,
450
- borderRadius: knobProps.borderRadius.borderRadius,
451
- elevation: knobProps.elevation,
452
- });
501
+ expect(knobProps.elevatedSurface.elevation).toBe(knobProps.overlayElevation);
502
+ expect(knobProps.elevatedSurface.elevation).not.toBe(knobProps.elevation);
503
+ expect(knobProps.elevatedSurface.backgroundColor).toBe(knobProps.surface.backgroundColor);
504
+ expect(knobProps.elevatedSurface.borderColor).toBe(knobProps.surface.borderColor);
505
+ expect(knobProps.elevatedSurface.borderWidth).toBe(knobProps.surface.borderWidth);
506
+ expect(knobProps.elevatedSurface.borderRadius).toBe(knobProps.borderRadius.borderRadius);
507
+ });
508
+
509
+ it("overlay elevation token differs from the control elevation token", () => {
510
+ for (const elevation of ["small", "medium", "large"] as const) {
511
+ const { knobProps } = resolveKnobs(knobsWith({ elevation }));
512
+ expect(knobProps.overlayElevation, elevation).toBeDefined();
513
+ expect(knobProps.overlayElevation, elevation).not.toBe(knobProps.elevation);
514
+ expect(knobProps.elevatedSurface.elevation, elevation).toBe(knobProps.overlayElevation);
515
+ }
516
+ const none = resolveKnobs(knobsWith({ elevation: "none" }));
517
+ expect(none.knobProps.elevation).toBeUndefined();
518
+ expect(none.knobProps.overlayElevation).toBeUndefined();
519
+ expect(none.knobProps.elevatedSurface.elevation).toBeUndefined();
453
520
  });
454
521
 
455
522
  it("featureSurface uses big radius, $6 padding, and an ultra-soft shadow", () => {
@@ -514,8 +581,7 @@ describe("resolveKnobs", () => {
514
581
  ).toBe("$6");
515
582
  });
516
583
 
517
- it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", async () => {
518
- const { tokens } = await import("@tamagui/themes");
584
+ it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", () => {
519
585
  const radiusTokenByKnob = {
520
586
  none: "$0",
521
587
  small: "$2",
@@ -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,34 @@ 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
+
1136
+ it("names which gap density moves: the LAYOUT gap, never the recipe gap (LC-79)", () => {
1137
+ // `control.gap` tracks the RADIUS knob, so it is invariant under density
1138
+ // by construction. The gap a reader sees tighten is `knobProps.gap`, the
1139
+ // space-ramp one, and it only moves once `applyDensity` has stepped
1140
+ // `space` — `resolveKnobs` alone is a pure token mapper.
1141
+ const comfortable = resolveKnobs(applyDensity(knobsWith({ space: "medium" })));
1142
+ const compact = resolveKnobs(applyDensity(knobsWith({ space: "medium", density: "compact" })));
1143
+ expect(compact.knobProps.control.gap).toBe(comfortable.knobProps.control.gap);
1144
+ expect(compact.knobProps.gap).not.toEqual(comfortable.knobProps.gap);
1145
+ expect(compact.knobProps.panelPadding).not.toEqual(comfortable.knobProps.panelPadding);
1146
+ expect(compact.knobProps.control.height).toBe(44);
1147
+ expect(comfortable.knobProps.control.height).toBe(44);
1148
+ });
1149
+
1008
1150
  it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
1009
1151
  const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
1010
1152
  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",
@@ -90,8 +97,9 @@ const borderWidthMap = {
90
97
  // ── Corner smoothing (Axiom 15 OPTICS) ────────────────────────
91
98
  // `smooth` upgrades circular-arc corners to continuous curvature —
92
99
  // `corner-shape: squircle` (≡ superellipse(2), ≈ Apple / Figma 0.6) — as a
93
- // web progressive enhancement: Chromium 139+ paints it, every other engine
94
- // drops the unknown declaration and keeps the plain border-radius arc.
100
+ // web progressive enhancement: Chromium paints it (measured from 143; WebKit
101
+ // 26 and Firefox 148 drop the unknown declaration and keep the plain
102
+ // border-radius arc — docs/design/boards/W1-corner-smoothing/).
95
103
  // Delivery is a class + mounted stylesheet (cornerSmoothing.ts) because
96
104
  // Tamagui strips unknown keys from the `style` prop (skipProps) but merges
97
105
  // `className`. Native resolves to `round` for now (SquircleView adoption is
@@ -243,10 +251,121 @@ const fontWeightMap = {
243
251
  // be cast to java.lang.Double" (SB-N-04). The numbers are the exact
244
252
  // tokens.size values Tamagui resolves those tokens to ($1=20, $2=28, $4=44),
245
253
  // so Tamagui consumers render identical shadows either way.
246
- const elevationMap: Record<string, string | number | undefined> = isWeb
254
+ //
255
+ // Control elevation ($1 at the default small stop) is the in-page Button
256
+ // token. Overlay elevation is a DISTINCT token one step above that stop so
257
+ // a dialog over a scrim and a Button on the page do not read as the same
258
+ // distance off the surface (DG-OVL-01 / E-OVERLAY). elevatedSurface binds
259
+ // the overlay token — not the control one.
260
+ export const elevationMap: Record<string, string | number | undefined> = isWeb
247
261
  ? { none: undefined, small: "$1", medium: "$2", large: "$4" }
248
262
  : { none: undefined, small: 20, medium: 28, large: 44 };
249
263
 
264
+ export const overlayElevationMap: Record<string, string | number | undefined> = isWeb
265
+ ? { none: undefined, small: "$2", medium: "$3", large: "$5" }
266
+ : { none: undefined, small: 28, medium: 36, large: 52 };
267
+
268
+ /** Scheme the resolver was invoked with. Defaults light so unit tests stay stable. */
269
+ export type ColorScheme = "light" | "dark";
270
+
271
+ // Existing 12-step ramp (colorRules groups). Do not invent overlay tokens:
272
+ // dark elevation is a lighter $color step. Overlay/card rest on $color1;
273
+ // filled inputs rest on $color2 and step from there.
274
+ const elevationTintOverlay: Record<Elevation, string> = {
275
+ none: "$color1",
276
+ small: "$color2",
277
+ medium: "$color3",
278
+ large: "$color4",
279
+ };
280
+
281
+ const elevationTintInput: Record<Elevation, string> = {
282
+ none: "$color2",
283
+ small: "$color3",
284
+ medium: "$color4",
285
+ large: "$color5",
286
+ };
287
+
288
+ // Axiom 14 light source: y-offset:blur = 0.5. Metrics match the maps
289
+ // InputParts / getElevationWrapperProps already shipped.
290
+ const elevationShadowMetrics: Record<
291
+ "$1" | "$2" | "$4",
292
+ { offsetY: number; radius: number; android: number }
293
+ > = {
294
+ $1: { offsetY: 1, radius: 2, android: 2 },
295
+ $2: { offsetY: 3, radius: 6, android: 4 },
296
+ $4: { offsetY: 10, radius: 20, android: 8 },
297
+ };
298
+
299
+ const elevationKeyAlias: Record<string, "$1" | "$2" | "$4"> = {
300
+ $1: "$1",
301
+ $2: "$2",
302
+ $3: "$2",
303
+ $4: "$4",
304
+ $5: "$4",
305
+ "20": "$1",
306
+ "28": "$2",
307
+ "36": "$2",
308
+ "44": "$4",
309
+ "52": "$4",
310
+ };
311
+
312
+ const webShadowLight: Record<"$1" | "$2" | "$4", string> = {
313
+ $1: "0 1px 2px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08)",
314
+ $2: "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
315
+ $4: "0 10px 20px rgba(0,0,0,0.15), 0 3px 6px rgba(0,0,0,0.10)",
316
+ };
317
+
318
+ const webShadowDark: Record<"$1" | "$2" | "$4", string> = {
319
+ $1: "0 1px 2px rgba(255,255,255,0.08), 0 0 1px rgba(255,255,255,0.06)",
320
+ $2: "0 3px 6px rgba(255,255,255,0.10), 0 2px 4px rgba(255,255,255,0.06)",
321
+ $4: "0 10px 20px rgba(255,255,255,0.12), 0 3px 6px rgba(255,255,255,0.08)",
322
+ };
323
+
324
+ function elevationTokenKey(token: string | number | undefined): "$1" | "$2" | "$4" | undefined {
325
+ if (token == null) return undefined;
326
+ return elevationKeyAlias[String(token)];
327
+ }
328
+
329
+ function elevationPaint(
330
+ token: string | number | undefined,
331
+ scheme: ColorScheme,
332
+ ): ElevationPaintProps {
333
+ const key = elevationTokenKey(token);
334
+ if (!key) return {};
335
+ const metrics = elevationShadowMetrics[key];
336
+ const dark = scheme === "dark";
337
+ const paint: ElevationPaintProps = {
338
+ shadowColor: dark ? "$color12" : "#000",
339
+ shadowOffset: { width: 0, height: metrics.offsetY },
340
+ shadowOpacity: dark
341
+ ? key === "$4"
342
+ ? 0.12
343
+ : key === "$2"
344
+ ? 0.1
345
+ : 0.08
346
+ : key === "$1"
347
+ ? 0.12
348
+ : 0.15,
349
+ shadowRadius: metrics.radius,
350
+ };
351
+ if (isWeb) {
352
+ paint.style = { boxShadow: dark ? webShadowDark[key] : webShadowLight[key] };
353
+ }
354
+ return paint;
355
+ }
356
+
357
+ function elevationChromeFor(
358
+ token: string | number | undefined,
359
+ scheme: ColorScheme,
360
+ ): ElevationChromeProps {
361
+ const key = elevationTokenKey(token);
362
+ if (!key) return {};
363
+ return {
364
+ ...elevationPaint(token, scheme),
365
+ elevation: elevationShadowMetrics[key].android,
366
+ };
367
+ }
368
+
250
369
  // Legibility floor: body/label text must never land below ~4.5:1 against its
251
370
  // surface. $color11 is the lowest ramp step that clears AA on color1/2
252
371
  // surfaces, so "low" clamps there ($color9 measured 1.29-3.43:1 — SB-R-01).
@@ -317,7 +436,10 @@ function resolveDisabled(disabledStyle: Knobs["disabledStyle"]): DisabledRecipe
317
436
 
318
437
  // ── State style resolver ──────────────────────────────────────
319
438
 
320
- function resolveStateKnobs(state: StateKnobs): {
439
+ function resolveStateKnobs(
440
+ state: StateKnobs,
441
+ scheme: ColorScheme,
442
+ ): {
321
443
  control: ControlStateProps;
322
444
  text: TextStateProps;
323
445
  elevation: ElevationStateProps;
@@ -339,7 +461,12 @@ function resolveStateKnobs(state: StateKnobs): {
339
461
  control.padding = gapMap[state.space];
340
462
  }
341
463
  if (state.elevation !== undefined) {
342
- elevation.elevation = elevationMap[state.elevation];
464
+ const token = elevationMap[state.elevation];
465
+ elevation.elevation = token;
466
+ Object.assign(elevation, elevationPaint(token, scheme));
467
+ if (scheme === "dark" && state.elevation !== "none") {
468
+ elevation.backgroundColor = elevationTintOverlay[state.elevation];
469
+ }
343
470
  }
344
471
  if (state.textAccent !== undefined) {
345
472
  text.color = textAccentColorMap[state.textAccent];
@@ -358,15 +485,19 @@ function resolveStateKnobs(state: StateKnobs): {
358
485
 
359
486
  // ── Resolver ──────────────────────────────────────────────────
360
487
 
361
- export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): ResolvedKnobs {
488
+ export function resolveKnobs(
489
+ knobs: Knobs,
490
+ override?: KnobPropsOverride,
491
+ scheme: ColorScheme = "light",
492
+ ): ResolvedKnobs {
362
493
  const radiusToken = borderRadiusMap[knobs.borderRadius];
363
- const outerRadiusToken = outerRadiusMap[knobs.borderRadius];
364
- const borderWidthValue = borderWidthMap[knobs.borderWidth];
494
+ const nestedRadiusToken = nestedRadiusMap[knobs.borderRadius];
365
495
  const sizeToken = sizeTokenMap[knobs.size];
366
496
  const nestedPx = nestedControlPxMap[knobs.size];
367
497
  const nestedHitSlop = pressTargetHitSlop(nestedPx);
368
498
  const sizeRecipe = sizeRecipeForToken(sizeToken, {
369
499
  touch: isTouchable || isWebTouchable,
500
+ family: knobs.density === "compact" ? "controlCompact" : "control",
370
501
  });
371
502
  const paddingToken = panelPaddingMap[knobs.space];
372
503
  const gapToken = gapMap[knobs.space];
@@ -393,18 +524,38 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
393
524
  const containerSmoothing = knobs.borderRadius === "none" ? undefined : smoothing;
394
525
 
395
526
  const outlined = knobs.fillStyle === "outlined";
527
+ // LC-86 OUTLINED-HAIRLINE: outlined implies a minimum border. none on an
528
+ // outlined control still draws the enumerated `small` stop (0.5). Chromeless
529
+ // is filled + none. Components must not add a second local floor.
530
+ const borderWidthValue =
531
+ outlined && knobs.borderWidth === "none"
532
+ ? borderWidthMap.small
533
+ : borderWidthMap[knobs.borderWidth];
396
534
  const elevationToken = elevationMap[knobs.elevation];
535
+ const overlayElevationToken = overlayElevationMap[knobs.elevation];
536
+ const overlayPaintProps = elevationPaint(overlayElevationToken, scheme);
537
+ const elevationChrome = elevationChromeFor(elevationToken, scheme);
538
+ const dark = scheme === "dark";
539
+ // MPO-19 X5: `$background` (not `$color1`) so surface1/2/3 sub-themes move
540
+ // the fill. Dark + elevation still steps the filled rest fill up the ramp
541
+ // (LC-85).
542
+ const overlayFill =
543
+ dark && knobs.elevation !== "none" ? elevationTintOverlay[knobs.elevation] : "$background";
397
544
  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);
545
+ // One input surface (DF-19 / Axiom 5 ONE SOURCE): Timeline's composer,
546
+ // RichEditor and form fields agree on what an input surface is.
547
+ // MPO-19 X6: `$background` not `$color2` so a surface sub-theme can move it.
548
+ // Dark + elevation steps the filled rest fill up the existing ramp (LC-85).
549
+ const inputBackground = outlined
550
+ ? "transparent"
551
+ : dark
552
+ ? elevationTintInput[knobs.elevation]
553
+ : "$background";
554
+
555
+ const hover = resolveStateKnobs(knobs.hover, scheme);
556
+ const press = resolveStateKnobs(knobs.press, scheme);
557
+ const focus = resolveStateKnobs(knobs.focus, scheme);
558
+ const focusVisible = resolveStateKnobs(knobs.focusVisible, scheme);
408
559
 
409
560
  const nonEmpty = <T extends object>(obj: T): T | undefined =>
410
561
  Object.keys(obj).length > 0 ? obj : undefined;
@@ -430,6 +581,8 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
430
581
  focusVisibleKnobProps: nonEmpty(focusVisible.elevation),
431
582
  };
432
583
 
584
+ const nestedRadius = { borderRadius: nestedRadiusToken, ...containerSmoothing };
585
+
433
586
  const knobProps: KnobProps = {
434
587
  borderRadius: {
435
588
  borderRadius: radiusToken,
@@ -437,33 +590,46 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
437
590
  borderWidth: borderWidthValue,
438
591
  ...scaleSmoothing,
439
592
  },
440
- borderRadiusOuter: { borderRadius: outerRadiusToken, ...containerSmoothing },
593
+ borderRadiusNested: nestedRadius,
594
+ // MPO-19 X12: the old name for the same fragment, kept for one release so
595
+ // an out-of-tree consumer does not break on the rename. Same object, so
596
+ // the two cannot drift while both exist.
597
+ borderRadiusOuter: nestedRadius,
598
+ controlBorderFilled: { borderColor: "transparent", borderWidth: borderWidthValue },
599
+ controlBorderOutlined: {
600
+ borderColor: "$borderColor",
601
+ borderWidth: Math.max(borderWidthValue, 1),
602
+ },
441
603
  // Axiom 1 child-clip cap: padded containers (Card/Panel/DialogContent)
442
604
  // spread this after their radius-bearing fragment. Non-container parts
443
605
  // keep the uncapped `borderRadius` fragment.
444
606
  containerRadius: { borderRadius: containerRadiusValue, ...containerSmoothing },
445
607
  elevation: elevationToken,
608
+ overlayElevation: overlayElevationToken,
609
+ elevationChrome,
610
+ scheme,
446
611
  surface: {
447
- backgroundColor: "$color1",
612
+ backgroundColor: "$background",
448
613
  borderColor: "$borderColor",
449
614
  borderWidth: borderWidthValue,
450
615
  },
451
616
  cardSurface: {
452
- backgroundColor: "$color1",
617
+ backgroundColor: overlayFill,
453
618
  borderRadius: containerRadiusValue,
454
619
  padding: paddingToken,
455
620
  ...containerSmoothing,
456
621
  },
457
622
  elevatedSurface: {
458
- backgroundColor: "$color1",
623
+ backgroundColor: overlayFill,
459
624
  borderColor: "$borderColor",
460
625
  borderWidth: borderWidthValue,
461
626
  borderRadius: radiusToken,
462
- elevation: elevationToken,
627
+ elevation: overlayElevationToken,
628
+ ...overlayPaintProps,
463
629
  ...scaleSmoothing,
464
630
  },
465
631
  featureSurface: {
466
- backgroundColor: "$color1",
632
+ backgroundColor: "$background",
467
633
  borderRadius: "$10",
468
634
  padding: "$6",
469
635
  // 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
+ });