@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
@@ -1,12 +1,26 @@
1
+ import { defaultConfig } from "@tamagui/config/v5";
1
2
  import { createInterFont } from "@tamagui/font-inter";
3
+ import type { GenericFont } from "tamagui";
2
4
 
3
5
  // Default purpose fonts for components that reference $heading / $body
4
6
  // directly, both backed by Inter.
5
- // Tables match @tamagui/config/v4 (the scale Bento is authored against):
6
- // headings 1.4x the base size scale (H1 $10 = 64px, H2 $9 = 42, H3 $8 = 32,
7
- // H4 $7 = 28) at default weight with zero tracking — large and light, not
8
- // small and bold — and body at the unscaled 14px/$true with lineHeight
9
- // size+10. No uppercase transforms.
7
+ //
8
+ // MPO-19 X8 + X9. These tables used to be @tamagui/config/v4's — headings on a
9
+ // 1.4x scale (H1 $10 = 64px) at whatever weight the knob happened to default
10
+ // to, body at 14px with `lineHeight = size + 10`. Against the v5 era this
11
+ // package now pins (see `TAMAGUI_ERA` in createDefaultThemeConfig.ts) that is a
12
+ // different typographic voice, not a variation on one: H1 was 64px/400 where
13
+ // stock v5 is 40px/800, large and light against compact and heavy. And
14
+ // `size + 10` is not a ratio at all — 1.91 at 11px, 1.22 at 46px — which is
15
+ // why v5 replaced it with an explicit taper.
16
+ //
17
+ // So the tables are no longer transcribed. They are READ from
18
+ // `defaultConfig.fonts`, which means the era pin is load-bearing rather than
19
+ // decorative: re-point it and the type scale follows, and there is no local
20
+ // copy that can drift from upstream between pins. That also retires the
21
+ // hand-mirrored `interBaseSizes` table this file used to carry purely so
22
+ // tracking could be computed per key — the size table it needed IS the one
23
+ // upstream ships.
10
24
  //
11
25
  // Tracking-by-size (Axiom 15 OPTICS + LC-11): letter-spacing is a function
12
26
  // of the rendered size, not a constant — slightly loose at caption sizes,
@@ -16,6 +30,11 @@ import { createInterFont } from "@tamagui/font-inter";
16
30
  // These values are Inter-specific: when the headingFont/bodyFont knob picks
17
31
  // another family, FontKnobStyles' per-category metrics (LC-11) neutralize
18
32
  // this table for fixed-pitch and connected-script categories.
33
+ //
34
+ // Category fonts (serif/mono/rounded/…) MUST be registered on this same
35
+ // size+10 ramp, including the `true` key. An unregistered `$serif` token
36
+ // collapses the label TEXT NODE to -apple-system 14px/normal — the frame
37
+ // stays Inter, so measuring the frame hides the defect (MPO-44).
19
38
 
20
39
  const TRACKING_A = -0.0223;
21
40
  const TRACKING_B = 0.185;
@@ -31,11 +50,20 @@ export function interTrackingPx(sizePx: number): number {
31
50
  return Math.round(interTrackingEm(sizePx) * sizePx * 100) / 100;
32
51
  }
33
52
 
53
+ /** Body leading stays on the ramp: size + 10 (14 → 24). Never CSS `normal`. */
54
+ export const BODY_LINE_HEIGHT_OFFSET = 10;
55
+
56
+ export function bodyLineHeightPx(sizePx: number): number {
57
+ return sizePx + BODY_LINE_HEIGHT_OFFSET;
58
+ }
59
+
34
60
  // Mirrors @tamagui/font-inter's default size table (pinned 2.0.0-rc.41) so
35
61
  // tracking can be computed per size key. The fonts.spec.ts invariant test
36
62
  // (letterSpacing[k] === interTrackingPx(size[k]) on the BUILT font) catches
37
- // any upstream drift.
38
- const interBaseSizes: Record<string, number> = {
63
+ // any upstream drift. `true` is the default size token — omit it and
64
+ // fontFamily="$serif" falls back to the system face with line-height:normal.
65
+ // Category fonts (not heading/body) stay on this transcribed ramp.
66
+ export const bodyFontSizes: Record<string, number> = {
39
67
  1: 11,
40
68
  2: 12,
41
69
  3: 13,
@@ -55,35 +83,135 @@ const interBaseSizes: Record<string, number> = {
55
83
  16: 134,
56
84
  };
57
85
 
58
- function trackingTable(scaleSize: (size: number) => number): Record<string, number> {
59
- return Object.fromEntries(
60
- Object.entries(interBaseSizes).map(([key, size]) => [key, interTrackingPx(scaleSize(size))]),
61
- );
86
+ /**
87
+ * A tamagui font table with Variables unwrapped and `$` prefixes stripped.
88
+ * Upstream hands these back either raw or wrapped depending on where in the
89
+ * config pipeline they were read, and `createInterFont` wants them raw.
90
+ */
91
+ function plain<T>(raw: unknown): Record<string, T> {
92
+ const out: Record<string, T> = {};
93
+ for (const [key, value] of Object.entries((raw ?? {}) as Record<string, T | { val?: T }>)) {
94
+ const unwrapped =
95
+ value && typeof value === "object" && "val" in value
96
+ ? (value as { val?: T }).val
97
+ : (value as T);
98
+ if (unwrapped !== undefined) out[key.replace(/^\$/, "")] = unwrapped;
99
+ }
100
+ return out;
62
101
  }
63
102
 
64
- const headingSizeOf = (size: number) => Math.round(size * 1.4);
103
+ /** A tamagui font table's numbers, with Variables and `$` prefixes unwrapped. */
104
+ const numbers = (raw: unknown) => plain<number>(raw);
65
105
 
66
- export const defaultHeadingFont = createInterFont(
67
- {
68
- face: {
69
- 700: { normal: "InterBold" },
106
+ /** Inter dynamic tracking for every key of a size table. */
107
+ function trackingFor(sizes: Record<string, number>): Record<string, number> {
108
+ return Object.fromEntries(Object.entries(sizes).map(([key, px]) => [key, interTrackingPx(px)]));
109
+ }
110
+
111
+ const v5Body = defaultConfig.fonts.body;
112
+ const v5Heading = defaultConfig.fonts.heading;
113
+ const bodySizes = numbers(v5Body.size);
114
+ const headingSizes = numbers(v5Heading.size);
115
+
116
+ /**
117
+ * Inter faces the workspace ships. v5 heading weights are sparse — 600 up to
118
+ * $5, 700 at $6–$8, 800 from $9 — and A4's reading of that is the point:
119
+ * everything between inherits, so bold is rare and therefore means something.
120
+ * Each declared weight needs a face or the browser synthesises one.
121
+ */
122
+ const interFaces = {
123
+ 400: { normal: "Inter" },
124
+ 600: { normal: "InterSemiBold" },
125
+ 700: { normal: "InterBold" },
126
+ 800: { normal: "InterExtraBold" },
127
+ } as const;
128
+
129
+ /**
130
+ * Concrete family stacks for each `headingFont`/`bodyFont` category.
131
+ * Distinct first families so a text-node measurement cannot collapse three
132
+ * stops onto one system fallback. Matches `packages/config/fonts.ts`
133
+ * (serif/mono are explicit system aliases; the rest are shipped webfonts).
134
+ */
135
+ export const fontFamilyStacks = {
136
+ "sans-serif":
137
+ 'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
138
+ serif: "Georgia, 'Times New Roman', Times, serif",
139
+ mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
140
+ slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
141
+ rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
142
+ condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
143
+ cursive: "'Dancing Script', 'Pacifico', cursive",
144
+ handwriting: "'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive",
145
+ pixel: "'Silkscreen', 'Press Start 2P', monospace",
146
+ blackletter: "'UnifrakturCook', 'Fraktur', serif",
147
+ geometric: "'Futura', 'Poppins', 'Century Gothic', sans-serif",
148
+ } as const;
149
+
150
+ export type FontFamilyCategory = keyof typeof fontFamilyStacks;
151
+
152
+ /**
153
+ * Category font on the body ramp: same size table as Inter (including
154
+ * `true: 14`) and lineHeight size+10. Inter tracking is NOT copied — that
155
+ * curve is Inter-specific (LC-11); FontKnobStyles applies per-category
156
+ * floors when the knob restyles `$heading`/`$body`.
157
+ */
158
+ export function createCategoryFont(family: string): GenericFont {
159
+ return createInterFont(
160
+ {
161
+ family,
162
+ letterSpacing: {},
70
163
  },
71
- letterSpacing: trackingTable(headingSizeOf),
72
- },
164
+ {
165
+ sizeLineHeight: bodyLineHeightPx,
166
+ },
167
+ );
168
+ }
169
+
170
+ export const defaultHeadingFont = createInterFont(
73
171
  {
74
- sizeSize: headingSizeOf,
75
- sizeLineHeight: (size) => size + 10,
172
+ face: interFaces,
173
+ size: headingSizes,
174
+ lineHeight: numbers(v5Heading.lineHeight),
175
+ weight: plain<string>(v5Heading.weight),
176
+ letterSpacing: trackingFor(headingSizes),
76
177
  },
178
+ { sizeSize: (size) => size },
77
179
  );
78
180
 
79
181
  export const defaultBodyFont = createInterFont(
80
182
  {
81
- face: {
82
- 700: { normal: "InterBold" },
83
- },
84
- letterSpacing: trackingTable((size) => size),
85
- },
86
- {
87
- sizeLineHeight: (size) => size + 10,
183
+ face: interFaces,
184
+ size: bodySizes,
185
+ lineHeight: numbers(v5Body.lineHeight),
186
+ weight: plain<string>(v5Body.weight),
187
+ letterSpacing: trackingFor(bodySizes),
88
188
  },
189
+ { sizeSize: (size) => size },
89
190
  );
191
+
192
+ export const defaultSerifFont = createCategoryFont(fontFamilyStacks.serif);
193
+ export const defaultMonoFont = createCategoryFont(fontFamilyStacks.mono);
194
+ export const defaultSlabFont = createCategoryFont(fontFamilyStacks.slab);
195
+ export const defaultRoundedFont = createCategoryFont(fontFamilyStacks.rounded);
196
+ export const defaultCondensedFont = createCategoryFont(fontFamilyStacks.condensed);
197
+ export const defaultCursiveFont = createCategoryFont(fontFamilyStacks.cursive);
198
+ export const defaultHandwritingFont = createCategoryFont(fontFamilyStacks.handwriting);
199
+ export const defaultPixelFont = createCategoryFont(fontFamilyStacks.pixel);
200
+ export const defaultBlackletterFont = createCategoryFont(fontFamilyStacks.blackletter);
201
+ export const defaultGeometricFont = createCategoryFont(fontFamilyStacks.geometric);
202
+
203
+ /** All purpose + category fonts registered by `createDefaultThemeConfig`. */
204
+ export const defaultFonts = {
205
+ heading: defaultHeadingFont,
206
+ body: defaultBodyFont,
207
+ serif: defaultSerifFont,
208
+ mono: defaultMonoFont,
209
+ slab: defaultSlabFont,
210
+ rounded: defaultRoundedFont,
211
+ condensed: defaultCondensedFont,
212
+ cursive: defaultCursiveFont,
213
+ handwriting: defaultHandwritingFont,
214
+ pixel: defaultPixelFont,
215
+ blackletter: defaultBlackletterFont,
216
+ geometric: defaultGeometricFont,
217
+ } as const;
@@ -1,4 +1,5 @@
1
1
  export * from "./accent";
2
2
  export * from "./base";
3
3
  export * from "./builderOptions";
4
+ export * from "./categoryFonts";
4
5
  export * from "./fonts";
@@ -1170,15 +1170,6 @@ export function ThemeDevtoolsPanel({
1170
1170
  }
1171
1171
  }
1172
1172
  }
1173
- const fill = partial.fillStyle ?? knobs.fillStyle;
1174
- const borderWidth = partial.borderWidth ?? knobs.borderWidth;
1175
- if (fill === "outlined" && borderWidth === "none") {
1176
- if (!pinnedKnobs.has("borderWidth")) {
1177
- partial.borderWidth = pick(["small", "medium", "large"] as const);
1178
- } else {
1179
- partial.fillStyle = "filled";
1180
- }
1181
- }
1182
1173
  setOverrides(newOverrides);
1183
1174
  setKnobs(partial);
1184
1175
  if (!pinnedKnobs.has("color") && themeColors && onThemeColorChange) {
@@ -1200,8 +1191,6 @@ export function ThemeDevtoolsPanel({
1200
1191
  expandedKnobs,
1201
1192
  activeProfile,
1202
1193
  animationOptions,
1203
- knobs.fillStyle,
1204
- knobs.borderWidth,
1205
1194
  ]);
1206
1195
 
1207
1196
  // -------------------------------------------------------------------------
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { __resetDevWarnSeen, warnOutlineNone, warnPositiveTabIndex } from "../devWarn";
3
+ import { compositeFocusRingCss, keyboardFocusRingProps } from "../keyboardFocusRing";
3
4
  import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
4
5
  import { createThemesBuilder } from "./createThemes";
5
6
  import { defaultAccentTheme } from "./defaults/accent";
@@ -11,6 +12,7 @@ import {
11
12
  FOCUS_RING_MIN_CONTRAST,
12
13
  FOCUS_RING_MIN_WIDTH,
13
14
  FOCUS_VISIBLE_RING,
15
+ FOCUS_VISIBLE_RING_INSET,
14
16
  STATE_LAYER_HOVER,
15
17
  STATE_LAYER_PRESS,
16
18
  withDefaultStateLayer,
@@ -19,10 +21,13 @@ import { defaultKnobs } from "./knobs";
19
21
  import { resolveKnobs } from "./resolveKnobs";
20
22
 
21
23
  describe("focusState tokens (DG-A11Y-01 / DG-STATE-01 / W12)", () => {
22
- it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract", () => {
24
+ it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract at offset 0 (F1)", () => {
23
25
  expect(FOCUS_VISIBLE_RING.outlineWidth).toBeGreaterThanOrEqual(FOCUS_RING_MIN_WIDTH);
24
26
  expect(FOCUS_VISIBLE_RING.outlineStyle).toBe("solid");
25
27
  expect(FOCUS_VISIBLE_RING.outlineColor).toBe("$outlineColor");
28
+ // Board verify pass F1: offset 0 — not −2 (band inside the fill, where it
29
+ // measured 1.17–1.31:1 on solid intents) and not +2 (a gapped halo).
30
+ expect(FOCUS_VISIBLE_RING.outlineOffset).toBe(0);
26
31
  expect(FOCUS_RING_MIN_CONTRAST).toBeGreaterThanOrEqual(3);
27
32
  });
28
33
 
@@ -107,6 +112,110 @@ describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
107
112
  expect(Math.min(c1, c2)).toBeGreaterThanOrEqual(FOCUS_RING_MIN_CONTRAST);
108
113
  });
109
114
  }
115
+
116
+ // MPO-41 — assert the RATIO on the surface the band actually draws on, not
117
+ // just the declaration. At offset 0 the band paints OUTSIDE the border box,
118
+ // on the page ground behind the control; intent Buttons pin the parent-scope
119
+ // `$outlineColor` literal on the ring (LC-51), so the pair that must clear
120
+ // the floor is base `outlineColor` × base ground — for accent and error
121
+ // controls alike, in both schemes.
122
+ for (const scheme of ["light", "dark"] as const) {
123
+ for (const intent of ["accent", "error"] as const) {
124
+ it(`${scheme} ${intent}: offset-0 ring clears ≥3:1 on the page ground it draws on`, () => {
125
+ const base = themes[scheme];
126
+ expect(themes[`${scheme}_${intent}`]).toBeTruthy();
127
+ const ring = normalizeToHex(base.outlineColor);
128
+ const ground = normalizeToHex(base.color1);
129
+ expect(ring).toBeTruthy();
130
+ expect(ground).toBeTruthy();
131
+ expect(
132
+ contrastRatio(relativeLuminance(ring!), relativeLuminance(ground!)),
133
+ ).toBeGreaterThanOrEqual(FOCUS_RING_MIN_CONTRAST);
134
+ });
135
+ }
136
+ }
137
+
138
+ it("the retired flat inset failed the floor over the solid intent fills (why offset is 0)", () => {
139
+ // The measured failures that forced the flip (MPO-41 / MPO-19 X7): a −2
140
+ // band paints INSIDE the control, so it had to contrast against the solid
141
+ // intent fill — light accent 1.27:1, light error 1.17:1, dark error
142
+ // 1.31:1. Locking these <3:1 keeps the offset-0 geometry load-bearing: if
143
+ // the fills ever lighten past the floor this test goes stale, not wrong.
144
+ for (const [scheme, intent] of [
145
+ ["light", "accent"],
146
+ ["light", "error"],
147
+ ["dark", "error"],
148
+ ] as const) {
149
+ const ring = normalizeToHex(themes[scheme].outlineColor);
150
+ const fill = normalizeToHex(themes[`${scheme}_${intent}`].color9);
151
+ expect(ring).toBeTruthy();
152
+ expect(fill).toBeTruthy();
153
+ expect(contrastRatio(relativeLuminance(ring!), relativeLuminance(fill!))).toBeLessThan(
154
+ FOCUS_RING_MIN_CONTRAST,
155
+ );
156
+ }
157
+ });
158
+ });
159
+
160
+ describe("the ring still paints inside a clipped / fused group (LC-71 §4 inset carve-out)", () => {
161
+ it("FOCUS_VISIBLE_RING_INSET is the same ring pulled fully inside the border box", () => {
162
+ expect(FOCUS_VISIBLE_RING_INSET).toMatchObject({
163
+ outlineWidth: FOCUS_RING_MIN_WIDTH,
164
+ outlineStyle: "solid",
165
+ outlineColor: "$outlineColor",
166
+ outlineOffset: -2,
167
+ });
168
+ // The band spans [outlineOffset, outlineOffset + outlineWidth] from the
169
+ // border edge. Outer edge ≤ 0 means a clipping ancestor (fused group
170
+ // clipped at the group radius, menu overlay, table frame — LC-72
171
+ // CONTAINER-CLIP) cannot sever any part of it.
172
+ expect(
173
+ FOCUS_VISIBLE_RING_INSET.outlineOffset + FOCUS_VISIBLE_RING_INSET.outlineWidth,
174
+ ).toBeLessThanOrEqual(0);
175
+ });
176
+
177
+ it("keyboardFocusRingProps — the manual ring for roving-focus composites — pins the inset", () => {
178
+ expect(keyboardFocusRingProps).toEqual(FOCUS_VISIBLE_RING_INSET);
179
+ });
180
+
181
+ it("ensureFocusVisibleRing honors the explicit per-call-site inset and defaults to 0", () => {
182
+ expect(ensureFocusVisibleRing({ outlineOffset: -2 }).outlineOffset).toBe(-2);
183
+ expect(ensureFocusVisibleRing({ outlineWidth: 3 }).outlineOffset).toBe(0);
184
+ expect(ensureFocusVisibleRing().outlineOffset).toBe(0);
185
+ });
186
+ });
187
+
188
+ describe("LC-71: keyboard-only; measure the ring / text node, not the frame", () => {
189
+ it("standalone ring paints a 2px band on the ring-carrying node's box at offset 0", () => {
190
+ // Measure the node that paints the outline (the ring carrier / text node),
191
+ // never an ancestor Frame. Band = [offset, offset + width] from THAT box.
192
+ const { outlineOffset, outlineWidth } = FOCUS_VISIBLE_RING;
193
+ expect(outlineWidth).toBe(2);
194
+ expect(outlineOffset).toBe(0);
195
+ expect(outlineOffset + outlineWidth).toBe(2);
196
+ });
197
+
198
+ it("chip dismiss rings the glyph node, not the 44px hit-box frame", () => {
199
+ const frameRule = compositeFocusRingCss.match(
200
+ /\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/,
201
+ );
202
+ const ringRule = compositeFocusRingCss.match(
203
+ /\.mp-chip-dismiss:focus-visible \.mp-chip-dismiss-ring\s*\{([^}]+)\}/,
204
+ );
205
+ expect(frameRule?.[1]).toMatch(/outline:\s*none/);
206
+ expect(ringRule?.[1]).toMatch(/outline:\s*2px\s+solid/);
207
+ expect(ringRule?.[1]).toMatch(/outline-offset:\s*0/);
208
+ expect(ringRule?.[1]).not.toMatch(/outline-offset:\s*2px/);
209
+ });
210
+
211
+ it("non-text composites ring on :focus-visible only; text-entry keeps the any-focus carve-out", () => {
212
+ expect(compositeFocusRingCss).toMatch(/\.mp-composite-ring:focus-visible/);
213
+ expect(compositeFocusRingCss).toMatch(/\.mp-composite-ring-deep:has\(:focus-visible\)/);
214
+ expect(compositeFocusRingCss).toMatch(/\.mp-chip-dismiss:focus-visible/);
215
+ // Text-entry carve-out (LC-71 §1a): focusing an input means typing.
216
+ expect(compositeFocusRingCss).toMatch(/:has\(input:focus\)/);
217
+ expect(compositeFocusRingCss).toMatch(/:has\(textarea:focus\)/);
218
+ });
110
219
  });
111
220
 
112
221
  describe("resolveKnobs control recipes (W12)", () => {
@@ -47,18 +47,43 @@ export function focusRingColorMeetsContrast(ringColor: string, surfaceColor: str
47
47
  }
48
48
 
49
49
  /**
50
- * ONE ring geometry (DG-A11Y-01): 2px solid `$outlineColor`, inset −2.
51
- * Inset is the only offset that survives fused/clipped groups (segmented
52
- * controls clip children at the group radius; an outset ring paints square
53
- * across the clip or disappears under `overflow: hidden`) and it matches
54
- * `keyboardFocusRingProps`, the manual ring painted for roving-focus
55
- * widgets — so keyboard focus reads identically everywhere.
50
+ * ONE ring geometry (DG-A11Y-01 / LC-71 §4): 2px solid `$outlineColor` at
51
+ * offset **0** — the band hugs the border box and paints OUTSIDE it, on the
52
+ * surface behind the control (reference §10; board F1 verify pass: offset 0,
53
+ * not −2 and not +2). Offset 0 is what makes the ≥3:1 floor computable: the
54
+ * ring contrasts against the page ground, not the control's own fill. The
55
+ * old flat inset (−2) painted the band INSIDE solid intent fills and sank to
56
+ * 1.17–1.31:1 (light accent 1.27, light error 1.17, dark error 1.31) where
57
+ * the same ring on the page ground clears 5.78–6.50:1 (MPO-41 / MPO-19 X7).
58
+ *
59
+ * The inset did not die — it moved to `FOCUS_VISIBLE_RING_INSET`, an
60
+ * explicit per-call-site carve-out for clipping ancestors. MPO-19 X7 names
61
+ * the same offset `FOCUS_RING_CLIPPED_OFFSET` so call sites do not type `-2`.
56
62
  */
63
+ export const FOCUS_RING_CLIPPED_OFFSET = -2;
57
64
  export const FOCUS_VISIBLE_RING = {
58
65
  outlineWidth: FOCUS_RING_MIN_WIDTH,
59
66
  outlineStyle: "solid" as const,
60
67
  outlineColor: "$outlineColor",
61
- outlineOffset: -2,
68
+ outlineOffset: 0,
69
+ } satisfies ControlStateProps;
70
+
71
+ /**
72
+ * The LC-71 §4 clipping carve-out: the SAME ring, pulled fully inside the
73
+ * border box (offset −2 + width 2 ⇒ the band spans [−2, 0] from the edge, so
74
+ * no clipping ancestor can sever it). ONLY for items inside `overflow`
75
+ * clipping — fused/segmented groups clipped at the group radius, edge-to-edge
76
+ * menu rows under a clipped overlay (LC-72 CONTAINER-CLIP), table cells.
77
+ * Standalone controls take `FOCUS_VISIBLE_RING`; call sites opt into the
78
+ * inset explicitly (this constant, or
79
+ * `ensureFocusVisibleRing({ outlineOffset: -2 })`). Verification measures the
80
+ * painted composition, never the declared offset alone — a ring box inset
81
+ * from the boundary reports a compliant offset while painting inside it
82
+ * (the F1 pseudoInset trap, mpo-fields-measured.md §7).
83
+ */
84
+ export const FOCUS_VISIBLE_RING_INSET = {
85
+ ...FOCUS_VISIBLE_RING,
86
+ outlineOffset: FOCUS_RING_CLIPPED_OFFSET,
62
87
  } satisfies ControlStateProps;
63
88
 
64
89
  /**
@@ -0,0 +1,24 @@
1
+ import type { FontCategory } from "./knobs";
2
+
3
+ /**
4
+ * Concrete stacks for each headingFont/bodyFont category. Used as the
5
+ * FontKnobStyles `--f-family` fallback when Tamagui has no `$serif` (etc.)
6
+ * font registered, and as the default category fonts on
7
+ * createDefaultThemeConfig so consumers do not have to pass a font map.
8
+ *
9
+ * Matches packages/config/fonts.ts (system serif/mono; webfonts for the rest).
10
+ */
11
+ export const fontCategoryStacks: Record<FontCategory, string> = {
12
+ "sans-serif":
13
+ 'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
14
+ serif: "Georgia, 'Times New Roman', Times, serif",
15
+ mono: "monospace",
16
+ slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
17
+ rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
18
+ condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
19
+ cursive: "'Dancing Script', 'Pacifico', cursive",
20
+ handwriting: "'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive",
21
+ pixel: "'Silkscreen', 'Press Start 2P', monospace",
22
+ blackletter: "'UnifrakturCook', 'Fraktur', serif",
23
+ geometric: "'Futura', 'Poppins', 'Century Gothic', sans-serif",
24
+ };
@@ -55,9 +55,22 @@ describe("buildFontKnobCss", () => {
55
55
  expect(css).toContain(":root .font_body { --f-family: monospace !important");
56
56
  });
57
57
 
58
- it("skips the family override for unregistered fonts but keeps the category's default leading", () => {
58
+ it("emits a fallback --f-family when serif is not registered in Tamagui (live SHC)", () => {
59
+ // Live console 2026-08-29: #mp-font-knob-styles was present but had no
60
+ // --f-family because SHC never registers a `serif` font. The knob still
61
+ // has to write a family, or headingFont/bodyFont are a no-op on painted
62
+ // $heading/$body text. Georgia is a system face — no webfont required.
63
+ const css = buildFontKnobCss("serif", "sans-serif", resolveFamily, noTables);
64
+ expect(css).toContain(
65
+ ":root .font_heading { --f-family: Georgia, 'Times New Roman', Times, serif !important; }",
66
+ );
67
+ expect(css).toContain(":where(:root .font_heading) { line-height:");
68
+ });
69
+
70
+ it("emits a fallback --f-family for other unregistered categories and keeps the category leading", () => {
59
71
  const css = buildFontKnobCss("cursive", "cursive", resolveFamily, noTables);
60
- expect(css).not.toContain("--f-family");
72
+ expect(css).toContain(":root .font_heading { --f-family:");
73
+ expect(css).toContain("cursive !important");
61
74
  // cursive heading floor 1.3 beats the 1.25 minimum; body max(1.5, 1.5)
62
75
  expect(css).toContain(":where(:root .font_heading) { line-height: 1.3; }");
63
76
  expect(css).toContain(":where(:root .font_body) { line-height: 1.5; }");
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveGlyphPaint } from "./glyphPaint";
3
+
4
+ describe("resolveGlyphPaint (LC-84)", () => {
5
+ it("never returns undefined", () => {
6
+ expect(resolveGlyphPaint({})).toBe("currentColor");
7
+ expect(resolveGlyphPaint({}, undefined)).toBe("currentColor");
8
+ expect(resolveGlyphPaint({}, null)).toBe("currentColor");
9
+ expect(resolveGlyphPaint({}, "")).toBe("currentColor");
10
+ expect(resolveGlyphPaint({ color: {} })).toBe("currentColor");
11
+ });
12
+
13
+ it("reads .val, never a missing get(web) path", () => {
14
+ expect(resolveGlyphPaint({ color: { val: "#fafafa" } })).toBe("#fafafa");
15
+ expect(resolveGlyphPaint({ color10: { val: "#abcabc" } }, "$color10")).toBe("#abcabc");
16
+ expect(resolveGlyphPaint({ placeholderColor: { val: "#888" } }, "placeholderColor")).toBe(
17
+ "#888",
18
+ );
19
+ });
20
+
21
+ it("passes through already-resolved colors and falls back to ink", () => {
22
+ expect(resolveGlyphPaint({ color: { val: "#111" } }, "#ff00aa")).toBe("#ff00aa");
23
+ expect(resolveGlyphPaint({ color12: { val: "#eee" } })).toBe("#eee");
24
+ expect(resolveGlyphPaint({ color11: { val: "#ddd" } })).toBe("#ddd");
25
+ });
26
+ });
@@ -0,0 +1,46 @@
1
+ import { useTheme } from "tamagui";
2
+
3
+ type ThemeToken = { val?: unknown };
4
+
5
+ /**
6
+ * LC-84 — resolve a concrete glyph paint from a Tamagui theme token.
7
+ * Phosphor (and any SVG that defaults to `#000` / `currentColor` without a
8
+ * parent color) MUST receive this, never `theme.x.get("web")` which is
9
+ * undefined off-web and leaves the glyph black in dark mode.
10
+ *
11
+ * Never returns undefined.
12
+ */
13
+ export function resolveGlyphPaint(
14
+ theme: Record<string, ThemeToken | undefined>,
15
+ color?: string | null,
16
+ ): string {
17
+ const seen = new Set<string>();
18
+ const push = (value: unknown) => {
19
+ if (typeof value === "string" && value.length > 0 && value !== "undefined") {
20
+ seen.add(value);
21
+ }
22
+ };
23
+
24
+ if (typeof color === "string" && color.length > 0) {
25
+ const key = color.startsWith("$") ? color.slice(1) : color;
26
+ push(theme[key]?.val);
27
+ if (color.startsWith("#") || color.startsWith("rgb") || color.startsWith("hsl")) {
28
+ push(color);
29
+ } else if (!theme[key]) {
30
+ push(color);
31
+ }
32
+ }
33
+
34
+ push(theme.color?.val);
35
+ push(theme.color12?.val);
36
+ push(theme.color11?.val);
37
+
38
+ for (const value of seen) return value;
39
+ return "currentColor";
40
+ }
41
+
42
+ /** Live theme ink for Phosphor / SVG carets (LC-84). Never undefined. */
43
+ export function useGlyphColor(color?: string): string {
44
+ const theme = useTheme() as unknown as Record<string, ThemeToken | undefined>;
45
+ return resolveGlyphPaint(theme, color);
46
+ }
@@ -8,6 +8,8 @@ export * from "./createThemes";
8
8
  export * from "./defaults/index";
9
9
  export * from "./devtools/ThemeDevtoolsPanel";
10
10
  export * from "./focusState";
11
+ export * from "./glyphPaint";
12
+ export * from "./fontCategoryStacks";
11
13
  export * from "./FontKnobStyles";
12
14
  export * from "./groupGeometry";
13
15
  export * from "./hairline";
@@ -20,6 +22,7 @@ export type { Preset as PresetDefinition, IntentOverride } from "./preset.types"
20
22
  export * from "./PresetContext";
21
23
  export * from "./presets";
22
24
  export * from "./radiusClass";
25
+ export * from "./radiusClassTable.generated";
23
26
  export * from "./readableColor";
24
27
  export * from "./recipes";
25
28
  export * from "./resolveKnobs";
@@ -1,7 +1,16 @@
1
1
  // ── Structure ──────────────────────────────────────────────
2
2
 
3
+ /** Structural fill. Exactly two values (R9 G9): `filled` | `outlined`. Ghost is a Chip/Tag variant, not this knob. */
3
4
  export const FillStyle = { Filled: "filled", Outlined: "outlined" } as const;
4
5
  export type FillStyle = (typeof FillStyle)[keyof typeof FillStyle];
6
+ /** R9 G9 — adding a third FillStyle member fails this lock. */
7
+ type _FillStyleExactlyTwo = FillStyle extends "filled" | "outlined"
8
+ ? "filled" | "outlined" extends FillStyle
9
+ ? true
10
+ : never
11
+ : never;
12
+ const _fillStyleExactlyTwo: _FillStyleExactlyTwo = true;
13
+ void _fillStyleExactlyTwo;
5
14
 
6
15
  export const BorderRadius = {
7
16
  None: "none",
@@ -15,8 +24,10 @@ export type BorderRadius = (typeof BorderRadius)[keyof typeof BorderRadius];
15
24
  /**
16
25
  * Corner curvature continuity (Axiom 15 OPTICS). `smooth` renders
17
26
  * continuous-curvature (superellipse/squircle) corners where the platform
18
- * can (web: Chromium 139+ via `corner-shape`, others fall back to the plain
19
- * arc); `round` is today's circular-arc border-radius everywhere.
27
+ * can (web: `corner-shape: squircle`, computed `superellipse(2)` on
28
+ * Chromium 152; engines without the property drop the declaration — that
29
+ * fallback is measured via `cssSupportsCornerShape`, not assumed); `round`
30
+ * is today's circular-arc border-radius everywhere.
20
31
  * Two values, not a numeric dial: every shipped smoothing system converges
21
32
  * on one blessed curve (Apple ≈ Figma 0.6 ≈ superellipse K=2), and a free
22
33
  * number invites per-screen drift (Axiom 5). `none` is not needed —
@@ -226,6 +237,11 @@ export type InteractionState = (typeof interactionStates)[number];
226
237
 
227
238
  export interface Knobs {
228
239
  fillStyle: FillStyle;
240
+ /**
241
+ * Per-role radius (MPO-21 R9): leftover radius maps onto DEFAULT /
242
+ * CIRCULAR-AT-FULL / BINARY / CONTAINER-CAP. No improvised radius prop
243
+ * and no fifth class on this interface.
244
+ */
229
245
  borderRadius: BorderRadius;
230
246
  /**
231
247
  * Corner curvature continuity (Axiom 15 OPTICS). Optional so existing
@@ -3,6 +3,7 @@ import {
3
3
  MIN_PRESS_TARGET,
4
4
  NARROW_BREAKPOINT,
5
5
  OVERLAY_ANCHOR_GAP,
6
+ OVERLAY_ATTACH_GAP,
6
7
  OVERLAY_BREAKPOINT,
7
8
  READING_WIDTH_CH,
8
9
  READING_WIDTH_MAX_CH,
@@ -69,8 +70,9 @@ describe("readingWidthStyle (DG-LAY-05)", () => {
69
70
  });
70
71
 
71
72
  describe("overlay anchoring", () => {
72
- it("pins the shared trigger↔overlay gap at 4px", () => {
73
+ it("pins the free-overlay gap at 4px and the attach gap at 0", () => {
73
74
  expect(OVERLAY_ANCHOR_GAP).toBe(4);
75
+ expect(OVERLAY_ATTACH_GAP).toBe(0);
74
76
  });
75
77
  });
76
78