@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,21 +1,76 @@
1
+ import { defaultConfig } from "@tamagui/config/v5";
1
2
  import { createInterFont } from "@tamagui/font-inter";
3
+ // `@tamagui/web`, not `tamagui`: createDefaultThemeConfig.spec.ts mocks the
4
+ // whole `tamagui` module down to `createTamagui`, and this module is on that
5
+ // spec's import graph.
6
+ import { createFont, type GenericFont } from "@tamagui/web";
7
+ import { flooredLineHeight, fontCategoryMetrics } from "../fontCategoryMetrics";
8
+ import { fontCategoryStacks } from "../fontCategoryStacks";
9
+ import type { FontCategory } from "../knobs";
2
10
 
3
11
  // Default purpose fonts for components that reference $heading / $body
4
12
  // 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.
13
+ //
14
+ // MPO-19 X8 + X9. These tables used to be @tamagui/config/v4's — headings on a
15
+ // 1.4x scale (H1 $10 = 64px) at whatever weight the knob happened to default
16
+ // to, body at 14px with `lineHeight = size + 10`. Against the v5 era this
17
+ // package now pins (see `TAMAGUI_ERA` in createDefaultThemeConfig.ts) that is a
18
+ // different typographic voice, not a variation on one: H1 was 64px/400 where
19
+ // stock v5 is 40px/800, large and light against compact and heavy. And
20
+ // `size + 10` is not a ratio at all — 1.91 at 11px, 1.22 at 46px — which is
21
+ // why v5 replaced it with an explicit taper.
22
+ //
23
+ // So the tables are no longer transcribed. They are READ from
24
+ // `defaultConfig.fonts`, which means the era pin is load-bearing rather than
25
+ // decorative: re-point it and the type scale follows, and there is no local
26
+ // copy that can drift from upstream between pins. That also retires the
27
+ // hand-mirrored `interBaseSizes` table this file used to carry purely so
28
+ // tracking could be computed per key — the size table it needed IS the one
29
+ // upstream ships.
10
30
  //
11
31
  // Tracking-by-size (Axiom 15 OPTICS + LC-11): letter-spacing is a function
12
32
  // of the rendered size, not a constant — slightly loose at caption sizes,
13
33
  // increasingly tight at display sizes (the same optical behavior Apple's SF
14
34
  // implements per point size). The curve is Inter's own dynamic-metrics
15
35
  // model (rsms.me/inter/dynmetrics): tracking(em) = a + b·e^(c·size).
16
- // These values are Inter-specific: when the headingFont/bodyFont knob picks
17
- // another family, FontKnobStyles' per-category metrics (LC-11) neutralize
18
- // this table for fixed-pitch and connected-script categories.
36
+ //
37
+ // ── Category fonts (MPO-44, re-derived against the era pin) ──────────────
38
+ //
39
+ // A bodyFont stop is a FAMILY choice. It must move the family and nothing
40
+ // else, so every category font is built from the era's own `$body` tables
41
+ // rather than a ramp of its own. Measured on the label text node before this
42
+ // change, both schemes:
43
+ //
44
+ // sans-serif Inter 15px/23px
45
+ // serif Georgia 14px/24px
46
+ // mono ui-monospace 14px/24px
47
+ // rounded Nunito 14px/24px
48
+ //
49
+ // Registering the families (the first half of MPO-44) stopped the collapse to
50
+ // -apple-system 14px/normal, but the era pin landed after it and moved `$body`
51
+ // to 15/23 — so ten of eleven stops were left on a second ladder, and flipping
52
+ // the knob resized the label by a pixel and moved its leading. Same defect
53
+ // class as the one MPO-44 was filed for: the knob was still taking type off
54
+ // the ramp, just more quietly.
55
+ //
56
+ // Three deliberate departures from `$body`, all upward and all LC-11:
57
+ //
58
+ // 1. Leading is floored per category — `flooredLineHeight` raises the era's
59
+ // value where the taper falls under the category's ratio, never lowers
60
+ // it. Only display steps move; every text step is `$body`'s own value.
61
+ // 2. Tracking is 0, not Inter's curve. That curve is Inter-specific, and an
62
+ // empty table is not neutral: the class Tamagui emits for `$mono` does
63
+ // not declare `--f-letterSpacing-*`, so the value fell through to the
64
+ // `:root` Inter one and Georgia and Nunito rendered at Inter's −0.13px.
65
+ // There is no measured optical curve for these faces, so 0 is the honest
66
+ // value rather than a borrowed one.
67
+ // 3. Weight is the era's (400 at every step). The previous default was 300,
68
+ // which is off the reference's label weight in the other direction.
69
+ //
70
+ // `$body` itself is untouched: it IS upstream's table under the pin, and
71
+ // MPO-19 owns it. Its ratio dips below LC-11's 1.4 body floor from $11 up;
72
+ // FontKnobStyles floors that scope on web, and correcting the table would
73
+ // have to be argued on MPO-19, not smuggled in here.
19
74
 
20
75
  const TRACKING_A = -0.0223;
21
76
  const TRACKING_B = 0.185;
@@ -31,59 +86,168 @@ export function interTrackingPx(sizePx: number): number {
31
86
  return Math.round(interTrackingEm(sizePx) * sizePx * 100) / 100;
32
87
  }
33
88
 
34
- // Mirrors @tamagui/font-inter's default size table (pinned 2.0.0-rc.41) so
35
- // tracking can be computed per size key. The fonts.spec.ts invariant test
36
- // (letterSpacing[k] === interTrackingPx(size[k]) on the BUILT font) catches
37
- // any upstream drift.
38
- const interBaseSizes: Record<string, number> = {
39
- 1: 11,
40
- 2: 12,
41
- 3: 13,
42
- 4: 14,
43
- true: 14,
44
- 5: 16,
45
- 6: 18,
46
- 7: 20,
47
- 8: 23,
48
- 9: 30,
49
- 10: 46,
50
- 11: 55,
51
- 12: 62,
52
- 13: 72,
53
- 14: 92,
54
- 15: 114,
55
- 16: 134,
56
- };
57
-
58
- function trackingTable(scaleSize: (size: number) => number): Record<string, number> {
89
+ /**
90
+ * Legacy `size + 10` leading. Kept for `createDefaultFont` callers that pass
91
+ * their own size table and therefore have no era leading to floor.
92
+ */
93
+ export const BODY_LINE_HEIGHT_OFFSET = 10;
94
+
95
+ export function bodyLineHeightPx(sizePx: number): number {
96
+ return sizePx + BODY_LINE_HEIGHT_OFFSET;
97
+ }
98
+
99
+ /**
100
+ * A tamagui font table with Variables unwrapped and `$` prefixes stripped.
101
+ * Upstream hands these back either raw or wrapped depending on where in the
102
+ * config pipeline they were read, and `createInterFont` wants them raw.
103
+ */
104
+ function plain<T>(raw: unknown): Record<string, T> {
105
+ const out: Record<string, T> = {};
106
+ for (const [key, value] of Object.entries((raw ?? {}) as Record<string, T | { val?: T }>)) {
107
+ const unwrapped =
108
+ value && typeof value === "object" && "val" in value
109
+ ? (value as { val?: T }).val
110
+ : (value as T);
111
+ if (unwrapped !== undefined) out[key.replace(/^\$/, "")] = unwrapped;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** A tamagui font table's numbers, with Variables and `$` prefixes unwrapped. */
117
+ const numbers = (raw: unknown) => plain<number>(raw);
118
+
119
+ /** Inter dynamic tracking for every key of a size table. */
120
+ function trackingFor(sizes: Record<string, number>): Record<string, number> {
121
+ return Object.fromEntries(Object.entries(sizes).map(([key, px]) => [key, interTrackingPx(px)]));
122
+ }
123
+
124
+ /** Zero tracking for every key of a size table (LC-11, non-Inter faces). */
125
+ function neutralTracking(sizes: Record<string, number>): Record<string, number> {
126
+ return Object.fromEntries(Object.keys(sizes).map((key) => [key, 0]));
127
+ }
128
+
129
+ const v5Body = defaultConfig.fonts.body;
130
+ const v5Heading = defaultConfig.fonts.heading;
131
+ const bodySizes = numbers(v5Body.size);
132
+ const bodyLineHeights = numbers(v5Body.lineHeight);
133
+ const bodyWeights = plain<string>(v5Body.weight);
134
+ const headingSizes = numbers(v5Heading.size);
135
+
136
+ /**
137
+ * The era's body size table. Exported under its historical name because
138
+ * `createDefaultFont` defaults to it, so a consumer-registered category font
139
+ * lands on the same ladder as the built-in ones instead of a private copy
140
+ * that drifts at the next pin (MPO-19 X8/X9).
141
+ */
142
+ export const bodyFontSizes: Record<string, number> = bodySizes;
143
+
144
+ /** The era's body leading, before any per-category floor. */
145
+ export const bodyFontLineHeights: Record<string, number> = bodyLineHeights;
146
+
147
+ /**
148
+ * Inter faces the workspace ships. v5 heading weights are sparse — 600 up to
149
+ * $5, 700 at $6–$8, 800 from $9 — and A4's reading of that is the point:
150
+ * everything between inherits, so bold is rare and therefore means something.
151
+ * Each declared weight needs a face or the browser synthesises one.
152
+ */
153
+ const interFaces = {
154
+ 400: { normal: "Inter" },
155
+ 600: { normal: "InterSemiBold" },
156
+ 700: { normal: "InterBold" },
157
+ 800: { normal: "InterExtraBold" },
158
+ } as const;
159
+
160
+ /**
161
+ * Concrete family stacks for each `headingFont`/`bodyFont` category. Alias of
162
+ * the one table in `fontCategoryStacks.ts`, which `FontKnobStyles` also reads
163
+ * for its `--f-family` fallback — one stop cannot render two faces.
164
+ */
165
+ export const fontFamilyStacks = fontCategoryStacks;
166
+
167
+ export type FontFamilyCategory = FontCategory;
168
+
169
+ /**
170
+ * Leading for one category: the era's body table with the category's LC-11
171
+ * floor applied. Every text step comes back as `$body`'s own value; only the
172
+ * display steps, where the era's taper eases below the floor, move up.
173
+ */
174
+ export function categoryLineHeights(category: FontCategory): Record<string, number> {
175
+ const minRatio = (fontCategoryMetrics[category] ?? fontCategoryMetrics["sans-serif"]).body;
59
176
  return Object.fromEntries(
60
- Object.entries(interBaseSizes).map(([key, size]) => [key, interTrackingPx(scaleSize(size))]),
177
+ Object.entries(bodySizes).map(([key, px]) => [
178
+ key,
179
+ flooredLineHeight(px, bodyLineHeights[key] ?? bodyLineHeightPx(px), minRatio),
180
+ ]),
61
181
  );
62
182
  }
63
183
 
64
- const headingSizeOf = (size: number) => Math.round(size * 1.4);
184
+ /**
185
+ * Category font on the `$body` ladder: same sizes (including `true`), same
186
+ * weights, leading floored per LC-11, tracking neutral. A missing `true` key
187
+ * is what collapsed `$serif`/`$mono`/`$rounded` to the system face at
188
+ * `line-height: normal` (MPO-44).
189
+ */
190
+ export function createCategoryFont(category: FontCategory, family: string): GenericFont {
191
+ return createFont({
192
+ family,
193
+ size: bodySizes,
194
+ lineHeight: categoryLineHeights(category),
195
+ weight: bodyWeights,
196
+ letterSpacing: neutralTracking(bodySizes),
197
+ }) as GenericFont;
198
+ }
65
199
 
66
200
  export const defaultHeadingFont = createInterFont(
67
201
  {
68
- face: {
69
- 700: { normal: "InterBold" },
70
- },
71
- letterSpacing: trackingTable(headingSizeOf),
72
- },
73
- {
74
- sizeSize: headingSizeOf,
75
- sizeLineHeight: (size) => size + 10,
202
+ face: interFaces,
203
+ size: headingSizes,
204
+ lineHeight: numbers(v5Heading.lineHeight),
205
+ weight: plain<string>(v5Heading.weight),
206
+ letterSpacing: trackingFor(headingSizes),
76
207
  },
208
+ { sizeSize: (size) => size },
77
209
  );
78
210
 
79
211
  export const defaultBodyFont = createInterFont(
80
212
  {
81
- face: {
82
- 700: { normal: "InterBold" },
83
- },
84
- letterSpacing: trackingTable((size) => size),
85
- },
86
- {
87
- sizeLineHeight: (size) => size + 10,
213
+ face: interFaces,
214
+ size: bodySizes,
215
+ lineHeight: bodyLineHeights,
216
+ weight: bodyWeights,
217
+ letterSpacing: trackingFor(bodySizes),
88
218
  },
219
+ { sizeSize: (size) => size },
89
220
  );
221
+
222
+ export const defaultSerifFont = createCategoryFont("serif", fontFamilyStacks.serif);
223
+ export const defaultMonoFont = createCategoryFont("mono", fontFamilyStacks.mono);
224
+ export const defaultSlabFont = createCategoryFont("slab", fontFamilyStacks.slab);
225
+ export const defaultRoundedFont = createCategoryFont("rounded", fontFamilyStacks.rounded);
226
+ export const defaultCondensedFont = createCategoryFont("condensed", fontFamilyStacks.condensed);
227
+ export const defaultCursiveFont = createCategoryFont("cursive", fontFamilyStacks.cursive);
228
+ export const defaultHandwritingFont = createCategoryFont(
229
+ "handwriting",
230
+ fontFamilyStacks.handwriting,
231
+ );
232
+ export const defaultPixelFont = createCategoryFont("pixel", fontFamilyStacks.pixel);
233
+ export const defaultBlackletterFont = createCategoryFont(
234
+ "blackletter",
235
+ fontFamilyStacks.blackletter,
236
+ );
237
+ export const defaultGeometricFont = createCategoryFont("geometric", fontFamilyStacks.geometric);
238
+
239
+ /** All purpose + category fonts registered by `createDefaultThemeConfig`. */
240
+ export const defaultFonts = {
241
+ heading: defaultHeadingFont,
242
+ body: defaultBodyFont,
243
+ serif: defaultSerifFont,
244
+ mono: defaultMonoFont,
245
+ slab: defaultSlabFont,
246
+ rounded: defaultRoundedFont,
247
+ condensed: defaultCondensedFont,
248
+ cursive: defaultCursiveFont,
249
+ handwriting: defaultHandwritingFont,
250
+ pixel: defaultPixelFont,
251
+ blackletter: defaultBlackletterFont,
252
+ geometric: defaultGeometricFont,
253
+ } 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,119 @@ 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 failures that forced the flip (MPO-41 / MPO-19 X7): a −2 band paints
140
+ // INSIDE the control, so it had to contrast against the solid intent fill.
141
+ //
142
+ // Provenance, because the two numbers are easy to conflate. The headline
143
+ // figures — light accent 1.27:1, light error 1.17:1, dark error 1.31:1 —
144
+ // are RENDERED measurements off the action-and-feedback board at DPR 2,
145
+ // ring node against the Button's painted background. This test computes a
146
+ // DIFFERENT pair: the base `outlineColor` against the intent sub-theme's
147
+ // `color9` ink step (1.67 / 1.55 / 1.44 in this build). Same verdict, same
148
+ // reason, different surfaces — the board's error fills (#ce2c31 / #ff9794)
149
+ // are not in these default sub-theme ramps at all, so the fill side is the
150
+ // board's to own and not this package's.
151
+ //
152
+ // What this assertion IS: no sub-theme ink step the inset band could land
153
+ // on clears the floor, which keeps the offset-0 geometry load-bearing. If
154
+ // the ramps ever lighten past 3:1 this test goes stale, not wrong.
155
+ for (const [scheme, intent] of [
156
+ ["light", "accent"],
157
+ ["light", "error"],
158
+ ["dark", "error"],
159
+ ] as const) {
160
+ const ring = normalizeToHex(themes[scheme].outlineColor);
161
+ const fill = normalizeToHex(themes[`${scheme}_${intent}`].color9);
162
+ expect(ring).toBeTruthy();
163
+ expect(fill).toBeTruthy();
164
+ expect(contrastRatio(relativeLuminance(ring!), relativeLuminance(fill!))).toBeLessThan(
165
+ FOCUS_RING_MIN_CONTRAST,
166
+ );
167
+ }
168
+ });
169
+ });
170
+
171
+ describe("the ring still paints inside a clipped / fused group (LC-71 §4 inset carve-out)", () => {
172
+ it("FOCUS_VISIBLE_RING_INSET is the same ring pulled fully inside the border box", () => {
173
+ expect(FOCUS_VISIBLE_RING_INSET).toMatchObject({
174
+ outlineWidth: FOCUS_RING_MIN_WIDTH,
175
+ outlineStyle: "solid",
176
+ outlineColor: "$outlineColor",
177
+ outlineOffset: -2,
178
+ });
179
+ // The band spans [outlineOffset, outlineOffset + outlineWidth] from the
180
+ // border edge. Outer edge ≤ 0 means a clipping ancestor (fused group
181
+ // clipped at the group radius, menu overlay, table frame — LC-72
182
+ // CONTAINER-CLIP) cannot sever any part of it.
183
+ expect(
184
+ FOCUS_VISIBLE_RING_INSET.outlineOffset + FOCUS_VISIBLE_RING_INSET.outlineWidth,
185
+ ).toBeLessThanOrEqual(0);
186
+ });
187
+
188
+ it("keyboardFocusRingProps — the manual ring for roving-focus composites — pins the inset", () => {
189
+ expect(keyboardFocusRingProps).toEqual(FOCUS_VISIBLE_RING_INSET);
190
+ });
191
+
192
+ it("ensureFocusVisibleRing honors the explicit per-call-site inset and defaults to 0", () => {
193
+ expect(ensureFocusVisibleRing({ outlineOffset: -2 }).outlineOffset).toBe(-2);
194
+ expect(ensureFocusVisibleRing({ outlineWidth: 3 }).outlineOffset).toBe(0);
195
+ expect(ensureFocusVisibleRing().outlineOffset).toBe(0);
196
+ });
197
+ });
198
+
199
+ describe("LC-71: keyboard-only; measure the ring / text node, not the frame", () => {
200
+ it("standalone ring paints a 2px band on the ring-carrying node's box at offset 0", () => {
201
+ // Measure the node that paints the outline (the ring carrier / text node),
202
+ // never an ancestor Frame. Band = [offset, offset + width] from THAT box.
203
+ const { outlineOffset, outlineWidth } = FOCUS_VISIBLE_RING;
204
+ expect(outlineWidth).toBe(2);
205
+ expect(outlineOffset).toBe(0);
206
+ expect(outlineOffset + outlineWidth).toBe(2);
207
+ });
208
+
209
+ it("chip dismiss rings the glyph node, not the 44px hit-box frame", () => {
210
+ const frameRule = compositeFocusRingCss.match(/\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/);
211
+ const ringRule = compositeFocusRingCss.match(
212
+ /\.mp-chip-dismiss:focus-visible \.mp-chip-dismiss-ring\s*\{([^}]+)\}/,
213
+ );
214
+ expect(frameRule?.[1]).toMatch(/outline:\s*none/);
215
+ expect(ringRule?.[1]).toMatch(/outline:\s*2px\s+solid/);
216
+ expect(ringRule?.[1]).toMatch(/outline-offset:\s*0/);
217
+ expect(ringRule?.[1]).not.toMatch(/outline-offset:\s*2px/);
218
+ });
219
+
220
+ it("non-text composites ring on :focus-visible only; text-entry keeps the any-focus carve-out", () => {
221
+ expect(compositeFocusRingCss).toMatch(/\.mp-composite-ring:focus-visible/);
222
+ expect(compositeFocusRingCss).toMatch(/\.mp-composite-ring-deep:has\(:focus-visible\)/);
223
+ expect(compositeFocusRingCss).toMatch(/\.mp-chip-dismiss:focus-visible/);
224
+ // Text-entry carve-out (LC-71 §1a): focusing an input means typing.
225
+ expect(compositeFocusRingCss).toMatch(/:has\(input:focus\)/);
226
+ expect(compositeFocusRingCss).toMatch(/:has\(textarea:focus\)/);
227
+ });
110
228
  });
111
229
 
112
230
  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,57 @@
1
+ import type { FontCategory } from "./knobs";
2
+
3
+ /**
4
+ * Per-category legibility floors (LC-11 FONT-FAMILY-METRICS).
5
+ *
6
+ * `heading` / `body` are minimum lineHeight:fontSize ratios. `letterSpacing:
7
+ * "neutral"` marks the families where Inter's tracking-by-size curve is
8
+ * actively wrong — fixed-pitch faces must keep their pitch, and connected
9
+ * scripts break when tracked apart.
10
+ *
11
+ * Two consumers read this table and they must never drift apart:
12
+ *
13
+ * - `FontKnobStyles` emits `--f-lineHeight-N` overrides on the `.font_body`
14
+ * / `.font_heading` CSS scopes, which is how the knob restyles text that
15
+ * never asked for a category token (web only).
16
+ * - `defaults/fonts.ts` bakes the same floors into each registered category
17
+ * font's own lineHeight table, which is the path the knob actually takes:
18
+ * `bodyFont: "serif"` renders `.font_serif`, and the CSS scope rules above
19
+ * are keyed to `.font_body`, so they never reach it. Native has no CSS at
20
+ * all and depends on the table entirely.
21
+ *
22
+ * "sans-serif" is the default scope and is listed for completeness — the
23
+ * `$body` / `$heading` tables are upstream's under the v5 era pin (MPO-19
24
+ * X8/X9) and this file does not rewrite them.
25
+ */
26
+ export const fontCategoryMetrics: Record<
27
+ FontCategory,
28
+ { heading: number; body: number; letterSpacing: "inherit" | "neutral" }
29
+ > = {
30
+ "sans-serif": { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
31
+ serif: { heading: 1.2, body: 1.4, letterSpacing: "inherit" },
32
+ mono: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
33
+ // Heading floors below are sized to each configured face's em box
34
+ // (ascent+descent) so display headings never clip (LC-11): Roboto Slab
35
+ // ≈1.32, Nunito ≈1.36, Silkscreen ≈1.3, Poppins/Futura ≈1.4.
36
+ slab: { heading: 1.35, body: 1.4, letterSpacing: "inherit" },
37
+ rounded: { heading: 1.4, body: 1.4, letterSpacing: "inherit" },
38
+ condensed: { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
39
+ cursive: { heading: 1.3, body: 1.5, letterSpacing: "neutral" },
40
+ handwriting: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
41
+ pixel: { heading: 1.35, body: 1.4, letterSpacing: "neutral" },
42
+ blackletter: { heading: 1.3, body: 1.45, letterSpacing: "neutral" },
43
+ geometric: { heading: 1.45, body: 1.4, letterSpacing: "inherit" },
44
+ };
45
+
46
+ /**
47
+ * Leading for one size step: the era's own value, raised to the category's
48
+ * floor when the era's taper falls under it. Never below the base, so a
49
+ * category can only ever ask for MORE room than `$body` takes, never less.
50
+ */
51
+ export function flooredLineHeight(
52
+ sizePx: number,
53
+ baseLineHeightPx: number,
54
+ minRatio: number,
55
+ ): number {
56
+ return Math.max(baseLineHeightPx, Math.round(sizePx * minRatio));
57
+ }
@@ -0,0 +1,37 @@
1
+ import type { FontCategory } from "./knobs";
2
+
3
+ /**
4
+ * The one concrete family stack per headingFont/bodyFont category.
5
+ *
6
+ * Both paths read THIS table, so a stop cannot render one face through the
7
+ * registered font and another through the CSS fallback:
8
+ *
9
+ * - `defaults/fonts.ts` builds the registered `$serif` / `$mono` / … fonts
10
+ * from it (the path the knob takes on every platform).
11
+ * - `FontKnobStyles` falls back to it for `--f-family` when a consumer's
12
+ * config has no font registered for the category (live SHC console had
13
+ * the style tag and skipped the family).
14
+ *
15
+ * Distinct FIRST families throughout, because a text-node measurement reads
16
+ * the first resolvable name — three stops that all begin at a system
17
+ * fallback is the MPO-44 defect.
18
+ *
19
+ * Tracks `packages/config/fonts.ts` (serif/mono/geometric are system aliases,
20
+ * the rest are shipped webfonts). `mono` is the one deliberate widening: the
21
+ * template's bare `monospace` keyword leaves the face to the UA, so this
22
+ * names the platform faces ahead of it and keeps the keyword last.
23
+ */
24
+ export const fontCategoryStacks: Record<FontCategory, string> = {
25
+ "sans-serif":
26
+ 'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
27
+ serif: "Georgia, 'Times New Roman', Times, serif",
28
+ mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
29
+ slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
30
+ rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
31
+ condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
32
+ cursive: "'Dancing Script', 'Pacifico', cursive",
33
+ handwriting: "'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive",
34
+ pixel: "'Silkscreen', 'Press Start 2P', monospace",
35
+ blackletter: "'UnifrakturCook', 'Fraktur', serif",
36
+ geometric: "'Futura', 'Poppins', 'Century Gothic', sans-serif",
37
+ };
@@ -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; }");