@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,6 +1,5 @@
1
1
  import { defaultConfig, mediaQueryDefaultActive } from "@tamagui/config/v5";
2
2
  import { shorthands } from "@tamagui/shorthands";
3
- import { tokens } from "@tamagui/themes";
4
3
  import type { CreateTamaguiProps, GenericFont } from "tamagui";
5
4
  import { animations } from "./animations/index";
6
5
  import {
@@ -13,12 +12,30 @@ import {
13
12
  import { defaultAccentTheme } from "./defaults/accent";
14
13
  import { defaultBaseTheme } from "./defaults/base";
15
14
  import { defaultBuilderOptions } from "./defaults/builderOptions";
16
- import { defaultBodyFont, defaultHeadingFont } from "./defaults/fonts";
15
+ import { defaultFonts } from "./defaults/fonts";
17
16
  import type { Preset } from "./preset.types";
18
17
  import { type SizeRecipeInputs } from "./recipeInputs";
19
18
  import { type ThemeConfig, createThemeConfig } from "./shared";
20
19
  import { getGeneratedSizeRecipes, getSizeRecipeInputs, setSizeRecipeInputs } from "./sizeRecipes";
21
20
 
21
+ /**
22
+ * The Tamagui era this theme config resolves against (MPO-19 X13; rendered
23
+ * reference §12.2 C9 "unpinned theme package"). Every era-bearing default in
24
+ * this file — the `@tamagui/config/v5` spread, its media map,
25
+ * `mediaQueryDefaultActive` and the non-color token values — resolves on the
26
+ * v5 branch, and the MPO-19 correction rows (X4 interaction ramp, X5/X6
27
+ * surface addressing, X8/X9 heading weight and body size/leading) re-derive
28
+ * against v5, never against v4 or the legacy root exports. Asserted by the
29
+ * era block in `createDefaultThemeConfig.spec.ts`.
30
+ *
31
+ * Tokens come from `defaultConfig` and from nothing else — the legacy
32
+ * `@tamagui/themes` override is gone (measured identical for size/space/
33
+ * radius/zIndex; its only addition was unused Radix color steps).
34
+ */
35
+ export const TAMAGUI_ERA = "v5" as const;
36
+ /** MPO-19 proof alias — same pin as `TAMAGUI_ERA`. */
37
+ export const tamaguiEra = TAMAGUI_ERA;
38
+
22
39
  export interface CreateDefaultThemeConfigOptions {
23
40
  /** Base theme palette (dark/light with optional shadows; defaults to the built-in neutral ramps) */
24
41
  base?: BaseThemeBuilderDefinition;
@@ -26,7 +43,7 @@ export interface CreateDefaultThemeConfigOptions {
26
43
  accent?: ThemeBuilderDefinition;
27
44
  /** Map of extra named themes with their palettes */
28
45
  additionalThemes?: Record<string, ThemeBuilderDefinition>;
29
- /** Font definitions (merged over the default heading/body fonts) */
46
+ /** Font definitions (merged over the default heading/body and category fonts) */
30
47
  fonts?: Record<string, GenericFont>;
31
48
  /** Animation config (defaults to framework reanimated animations) */
32
49
  animations?: CreateTamaguiProps["animations"];
@@ -81,10 +98,11 @@ function composeGetTheme(
81
98
  * Builds a complete Tamagui theme config with batteries included: calling it
82
99
  * with zero options yields the framework's polished defaults — the neutral
83
100
  * Radix-mauve-curve base ramps, the solid-surface brand accent ramps, Inter
84
- * heading/body fonts and the interaction-ramp `getTheme` theme builder.
101
+ * heading/body fonts, the category fonts the bodyFont knob resolves, and the
102
+ * interaction-ramp `getTheme` theme builder.
85
103
  * Every option overrides its piece independently: `base` and `accent` each
86
104
  * replace their default, `fonts` are spread OVER the defaults so a partial
87
- * font map keeps the default heading/body, and `builderOptions.getTheme`
105
+ * font map keeps the default heading/body and category faces, and `builderOptions.getTheme`
88
106
  * COMPOSES with the default (consumer keys win; framework derivations like
89
107
  * the solid `$outlineColor` focus ring survive partial overrides).
90
108
  */
@@ -120,8 +138,7 @@ export function createDefaultThemeConfig(
120
138
  disableSSR: false,
121
139
  fonts: {
122
140
  ...defaultConfig.fonts,
123
- heading: defaultHeadingFont,
124
- body: defaultBodyFont,
141
+ ...defaultFonts,
125
142
  ...options.fonts,
126
143
  },
127
144
  shorthands,
@@ -129,7 +146,9 @@ export function createDefaultThemeConfig(
129
146
  themeClassNameOnRoot: true,
130
147
  themes,
131
148
  allowedStyleValues: undefined,
132
- tokens,
149
+ // Tokens come from `defaultConfig` above, and from nothing else — see
150
+ // the `tamaguiEra` note. There is no second token source to disagree
151
+ // with the era pin.
133
152
  settings: {
134
153
  ...defaultConfig.settings,
135
154
  allowedStyleValues: "somewhat-strict-web",
@@ -52,19 +52,32 @@ function pickFocusRingColor(theme: Record<string, string>): string {
52
52
  function getBaseTheme(theme: Record<string, string>) {
53
53
  return {
54
54
  // DG-A11Y-01: solid, scheme-aware focus ring (≥3:1 vs page surfaces).
55
+ // Additive — Tamagui's own ring is `rgba(ink, 0.2)`, which measured
56
+ // 1.36:1. This is an improvement and stays (M5).
55
57
  outlineColor: pickFocusRingColor(theme),
56
- // Normalize interaction ramp across components:
57
- // background: 5 -> 6 -> 4, border: 5 -> 6 -> 7.
58
- backgroundPress: theme.color5,
59
- backgroundFocus: theme.color6,
60
- backgroundActive: theme.color4,
61
58
  // Align placeholder/disabled text with the dedicated muted slot.
62
59
  placeholderColor: theme.color10,
63
- // Subtle default borders; focus/press one step deeper than hover.
64
- borderColor: theme.color5,
65
- borderColorHover: theme.color6,
66
- borderColorFocus: theme.color7,
67
- borderColorPress: theme.color7,
60
+ //
61
+ // MPO-19 X4 — SEVEN LINES DELETED HERE, deliberately.
62
+ //
63
+ // `backgroundPress: color5`, `backgroundFocus: color6`,
64
+ // `backgroundActive: color4`, `borderColor: color5`,
65
+ // `borderColorHover: color6`, `borderColorFocus: color7` and
66
+ // `borderColorPress: color7` used to sit in this object, re-anchoring
67
+ // Tamagui's whole interaction ramp one to three steps deeper. The only
68
+ // justification written down was "Normalize interaction ramp across
69
+ // components", and uniformity is an argument for HAVING a ramp, not for
70
+ // these values. The effect was that every mpo border read heavier and
71
+ // every focus fill landed darker than the same component in Tamagui.
72
+ //
73
+ // The theme builder's own templates already derive all seven from the
74
+ // background index, so deleting the pins does not leave a hole: it hands
75
+ // the keys back to the layer that owns them. If uniformity is wanted
76
+ // again, it gets re-derived from the template offsets rather than pinned
77
+ // as literal ramp steps.
78
+ //
79
+ // The semantic aliases below are a different thing and stay: they name
80
+ // slots Tamagui has no token for at all, so they contradict nothing.
68
81
  // Project-level semantic aliases for palette slots not covered by Tamagui defaults.
69
82
  // textMuted sits on the $color11 tier: the lowest ramp step that keeps
70
83
  // secondary text AA-readable on color1/2 surfaces (SB-R contrast sweep).
@@ -0,0 +1,24 @@
1
+ import { defaultFonts } from "./fonts";
2
+
3
+ /**
4
+ * Category fonts merged into createDefaultThemeConfig UNDER consumer
5
+ * heading/body overrides. FontKnobStyles looks these up as `$serif` etc.
6
+ * so headingFont=serif writes `--f-family` even when the app only customizes
7
+ * heading/body (SHC console Operator).
8
+ *
9
+ * Picked out of `defaultFonts` rather than built a second time: two
10
+ * construction paths meant `$mono` could be registered from one stack here
11
+ * and another there, which is the same one-source failure MPO-44 was about.
12
+ */
13
+ export const defaultCategoryFonts = {
14
+ serif: defaultFonts.serif,
15
+ mono: defaultFonts.mono,
16
+ slab: defaultFonts.slab,
17
+ rounded: defaultFonts.rounded,
18
+ condensed: defaultFonts.condensed,
19
+ cursive: defaultFonts.cursive,
20
+ handwriting: defaultFonts.handwriting,
21
+ pixel: defaultFonts.pixel,
22
+ blackletter: defaultFonts.blackletter,
23
+ geometric: defaultFonts.geometric,
24
+ };
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { defaultBodyFont, defaultHeadingFont, interTrackingPx } from "./fonts";
2
+ import { createDefaultFont } from "../../font";
3
+ import { flooredLineHeight, fontCategoryMetrics } from "../fontCategoryMetrics";
4
+ import { fontCategoryStacks } from "../fontCategoryStacks";
5
+ import type { FontCategory } from "../knobs";
6
+ import {
7
+ defaultBodyFont,
8
+ defaultFonts,
9
+ defaultHeadingFont,
10
+ fontFamilyStacks,
11
+ interTrackingPx,
12
+ } from "./fonts";
3
13
 
4
14
  type NumTable = Record<string, number>;
5
15
 
@@ -14,6 +24,17 @@ function table(font: unknown, key: "size" | "lineHeight" | "letterSpacing"): Num
14
24
  return out;
15
25
  }
16
26
 
27
+ /** Unwrap a font table whose values are strings (weight). */
28
+ function strTable(font: unknown, key: "weight"): Record<string, string> {
29
+ const raw = (font as Record<string, Record<string, string | { val: string }>>)[key] ?? {};
30
+ const out: Record<string, string> = {};
31
+ for (const [k, v] of Object.entries(raw)) {
32
+ const str = typeof v === "string" ? v : v && typeof v.val === "string" ? v.val : undefined;
33
+ if (str !== undefined) out[k.replace(/^\$/, "")] = str;
34
+ }
35
+ return out;
36
+ }
37
+
17
38
  describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-11)", () => {
18
39
  for (const [name, font] of [
19
40
  ["heading", defaultHeadingFont],
@@ -57,7 +78,9 @@ describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-1
57
78
  it("heading H1–H4 land on the expected optical tracking (snapshot)", () => {
58
79
  const size = table(defaultHeadingFont, "size");
59
80
  const letterSpacing = table(defaultHeadingFont, "letterSpacing");
60
- // H1 $10=64px, H2 $9=42px, H3 $8=32px, H4 $7=28px on the 1.4x scale
81
+ // MPO-19 X8: heading rides the body scale now, so H1 $10 = 40px at
82
+ // weight 800 rather than 64px at 400. Tracking follows the rendered
83
+ // size, so every value moves with it — that IS the invariant.
61
84
  expect({
62
85
  h1: { size: size["10"], tracking: letterSpacing["10"] },
63
86
  h2: { size: size["9"], tracking: letterSpacing["9"] },
@@ -66,22 +89,152 @@ describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-1
66
89
  }).toMatchInlineSnapshot(`
67
90
  {
68
91
  "h1": {
69
- "size": 64,
70
- "tracking": -1.43,
92
+ "size": 40,
93
+ "tracking": -0.89,
71
94
  },
72
95
  "h2": {
73
- "size": 42,
74
- "tracking": -0.93,
96
+ "size": 30,
97
+ "tracking": -0.64,
75
98
  },
76
99
  "h3": {
77
- "size": 32,
78
- "tracking": -0.69,
100
+ "size": 26,
101
+ "tracking": -0.53,
79
102
  },
80
103
  "h4": {
81
- "size": 28,
82
- "tracking": -0.59,
104
+ "size": 22,
105
+ "tracking": -0.4,
83
106
  },
84
107
  }
85
108
  `);
86
109
  });
87
110
  });
111
+
112
+ describe("bodyFont category fonts — one ladder, distinct families (MPO-44)", () => {
113
+ const bodyStops = ["body", "serif", "mono", "rounded"] as const;
114
+ const firstFamily = (stack: string) => stack.split(",")[0]?.replace(/['"]/g, "").trim();
115
+ const categories = Object.keys(defaultFonts).filter(
116
+ (name) => name !== "heading" && name !== "body",
117
+ ) as FontCategory[];
118
+ const bodySize = table(defaultBodyFont, "size");
119
+ const bodyLineHeight = table(defaultBodyFont, "lineHeight");
120
+
121
+ it("registers every category token the bodyFont knob maps to", () => {
122
+ expect(categories.length).toBe(10);
123
+ for (const key of categories) {
124
+ expect(defaultFonts[key], key).toBeTruthy();
125
+ expect(fontFamily(defaultFonts[key])).toBe(fontCategoryStacks[key]);
126
+ }
127
+ expect(fontFamily(defaultFonts.body)).toContain("Inter");
128
+ });
129
+
130
+ it("aliases fontFamilyStacks to the one fontCategoryStacks table", () => {
131
+ expect(fontFamilyStacks).toBe(fontCategoryStacks);
132
+ });
133
+
134
+ it("gives the four bodyFont stops four distinct first families", () => {
135
+ const firsts = bodyStops.map((key) => firstFamily(fontFamily(defaultFonts[key])));
136
+ expect(firsts).toEqual(["Inter", "Georgia", "ui-monospace", "Nunito"]);
137
+ expect(new Set(firsts).size).toBe(4);
138
+ });
139
+
140
+ it("every knob stop has a distinct first family — none is a system fallback", () => {
141
+ const firsts = ["body", ...categories].map((key) =>
142
+ firstFamily(fontFamily(defaultFonts[key as keyof typeof defaultFonts])),
143
+ );
144
+ expect(new Set(firsts).size).toBe(firsts.length);
145
+ expect(firsts).not.toContain("-apple-system");
146
+ });
147
+
148
+ it("puts every category font on the $body SIZE ladder — a family knob never resizes", () => {
149
+ for (const name of categories) {
150
+ expect(table(defaultFonts[name], "size"), `${name}.size`).toEqual(bodySize);
151
+ }
152
+ });
153
+
154
+ it("renders 15px/23px at $true and $4 on every stop, body included", () => {
155
+ for (const name of ["body", ...categories] as const) {
156
+ const size = table(defaultFonts[name as keyof typeof defaultFonts], "size");
157
+ const lineHeight = table(defaultFonts[name as keyof typeof defaultFonts], "lineHeight");
158
+ expect(size.true, `${name}.size.true`).toBe(15);
159
+ expect(size["4"], `${name}.size.4`).toBe(15);
160
+ expect(lineHeight.true, `${name}.lineHeight.true`).toBe(23);
161
+ expect(lineHeight["4"], `${name}.lineHeight.4`).toBe(23);
162
+ }
163
+ });
164
+
165
+ it("takes the era's leading, raised to the category floor and never lowered (LC-11)", () => {
166
+ for (const name of categories) {
167
+ const lineHeight = table(defaultFonts[name], "lineHeight");
168
+ const minRatio = fontCategoryMetrics[name].body;
169
+ for (const [key, px] of Object.entries(bodySize)) {
170
+ expect(lineHeight[key], `${name}.lineHeight[${key}]`).toBe(
171
+ flooredLineHeight(px, bodyLineHeight[key] as number, minRatio),
172
+ );
173
+ expect(lineHeight[key]).toBeGreaterThanOrEqual(bodyLineHeight[key] as number);
174
+ expect((lineHeight[key] as number) / px, `${name} ratio at ${key}`).toBeGreaterThanOrEqual(
175
+ minRatio - 0.034,
176
+ );
177
+ }
178
+ }
179
+ });
180
+
181
+ it("meets the LC-11 body floor at every size on every category", () => {
182
+ for (const name of categories) {
183
+ const size = table(defaultFonts[name], "size");
184
+ const lineHeight = table(defaultFonts[name], "lineHeight");
185
+ for (const [key, px] of Object.entries(size)) {
186
+ expect((lineHeight[key] as number) / px, `${name} ratio at ${key}`).toBeGreaterThan(1.36);
187
+ }
188
+ }
189
+ });
190
+
191
+ it("carries the era's weight, not the legacy 300", () => {
192
+ for (const name of categories) {
193
+ const weight = strTable(defaultFonts[name], "weight");
194
+ for (const key of Object.keys(bodySize)) {
195
+ expect(weight[key], `${name}.weight[${key}]`).toBe("400");
196
+ }
197
+ }
198
+ });
199
+
200
+ it("zeroes tracking on every category face so Inter's curve cannot leak in", () => {
201
+ for (const name of categories) {
202
+ const letterSpacing = table(defaultFonts[name], "letterSpacing");
203
+ expect(Object.keys(letterSpacing).sort(), `${name}.letterSpacing keys`).toEqual(
204
+ Object.keys(bodySize).sort(),
205
+ );
206
+ for (const key of Object.keys(bodySize)) {
207
+ expect(letterSpacing[key], `${name}.letterSpacing[${key}]`).toBe(0);
208
+ }
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("createDefaultFont — the era body ramp (MPO-44)", () => {
214
+ it("includes $true at 15/23 so app category fonts do not collapse to normal", () => {
215
+ const font = createDefaultFont({ family: "Georgia, serif" });
216
+ const size = table(font, "size");
217
+ const lineHeight = table(font, "lineHeight");
218
+ expect(size.true).toBe(15);
219
+ expect(lineHeight.true).toBe(23);
220
+ expect(fontFamily(font)).toBe("Georgia, serif");
221
+ });
222
+
223
+ it("lands a consumer font on the same ladder as the built-in category fonts", () => {
224
+ const font = createDefaultFont({ family: "Georgia, serif" });
225
+ expect(table(font, "size")).toEqual(table(defaultFonts.serif, "size"));
226
+ expect(table(font, "lineHeight")).toEqual(table(defaultFonts.rounded, "lineHeight"));
227
+ });
228
+
229
+ it("defaults to weight 400, never the legacy 300", () => {
230
+ const weight = strTable(createDefaultFont({ family: "Georgia, serif" }), "weight");
231
+ expect(weight["4"]).toBe("400");
232
+ });
233
+ });
234
+
235
+ function fontFamily(font: unknown): string {
236
+ const family = (font as { family?: string | { val?: string } }).family;
237
+ if (typeof family === "string") return family;
238
+ if (family && typeof family.val === "string") return family.val;
239
+ return "";
240
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ *
4
+ * Theme spec (MPO-44): bodyFont family, size and leading measured on the
5
+ * RENDERED TEXT NODE.
6
+ *
7
+ * Two traps this file exists to stay clear of.
8
+ *
9
+ * The button FRAME is always Inter — measuring it hid mono/serif/rounded
10
+ * collapsing to -apple-system 14px/normal. So walk to the text node.
11
+ *
12
+ * And the measurement must come out of `getComputedStyle`, never out of the
13
+ * class name. An earlier version of this spec read `font_serif` off the
14
+ * element and then looked the family up in the source table, which asserts
15
+ * that the table is the table. It also wrote `expect(parseFloat(lh) || 24)`,
16
+ * which passes when the leading is unreadable, and guarded the size check
17
+ * behind `if (fontSize)`. Every assertion below reads a real computed value
18
+ * and fails when there is none.
19
+ */
20
+
21
+ import { createElement } from "react";
22
+ import { cleanup, render } from "@testing-library/react";
23
+ import { afterEach, describe, expect, it, vi } from "vitest";
24
+
25
+ vi.mock("../animations/index", () => ({ animations: {} }));
26
+
27
+ import { TamaguiProvider, Text } from "tamagui";
28
+ import { createDefaultThemeConfig } from "../createDefaultThemeConfig";
29
+ import { defaultKnobs } from "../knobs";
30
+ import { resolveKnobs } from "../resolveKnobs";
31
+
32
+ const themeConfig = createDefaultThemeConfig();
33
+
34
+ /** The stops the button-knobs board shoots, in board order. */
35
+ const BODY_STOPS = [
36
+ { stop: "sans-serif", token: "body", firstFamily: "Inter" },
37
+ { stop: "serif", token: "serif", firstFamily: "Georgia" },
38
+ { stop: "mono", token: "mono", firstFamily: "ui-monospace" },
39
+ { stop: "rounded", token: "rounded", firstFamily: "Nunito" },
40
+ ] as const;
41
+
42
+ /** One ladder for every stop: `$true` is the era's body step (MPO-19 X8/X9). */
43
+ const LABEL_SIZE_PX = 15;
44
+ const LABEL_LEADING_PX = 23;
45
+
46
+ afterEach(cleanup);
47
+
48
+ /** Parent of the text node whose data is `label` — never a wrapping frame. */
49
+ function textNodeHost(root: HTMLElement, label: string): HTMLElement {
50
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
51
+ let node: Node | null = walker.nextNode();
52
+ while (node) {
53
+ if (node.textContent?.trim() === label && node.parentElement) {
54
+ return node.parentElement;
55
+ }
56
+ node = walker.nextNode();
57
+ }
58
+ throw new Error(`no text node "${label}"`);
59
+ }
60
+
61
+ function measureLabel(bodyFont: (typeof BODY_STOPS)[number]["stop"], scheme: "light" | "dark") {
62
+ const label = `Save-${bodyFont}`;
63
+ const knobs = resolveKnobs({ ...defaultKnobs, bodyFont });
64
+ const { container } = render(
65
+ createElement(
66
+ TamaguiProvider,
67
+ { config: themeConfig.tamagui, defaultTheme: scheme },
68
+ createElement(Text, { ...knobs.knobProps.body, fontSize: "$true" }, label),
69
+ ),
70
+ );
71
+ const host = textNodeHost(container, label);
72
+ const cs = getComputedStyle(host);
73
+ const px = (value: string) => {
74
+ const n = Number.parseFloat(value);
75
+ if (!Number.isFinite(n)) throw new Error(`unreadable length ${JSON.stringify(value)}`);
76
+ return n;
77
+ };
78
+ const family = cs.fontFamily.trim();
79
+ if (!family) throw new Error("no computed font-family on the text node");
80
+ return {
81
+ host,
82
+ knobs,
83
+ family,
84
+ firstFamily: family.split(",")[0]?.replace(/['"]/g, "").trim() ?? "",
85
+ fontSize: px(cs.fontSize),
86
+ lineHeight: cs.lineHeight.trim(),
87
+ lineHeightPx: px(cs.lineHeight),
88
+ letterSpacing: cs.letterSpacing.trim(),
89
+ };
90
+ }
91
+
92
+ describe("bodyFont text-node family, size and leading (MPO-44)", () => {
93
+ for (const scheme of ["light", "dark"] as const) {
94
+ describe(scheme, () => {
95
+ for (const { stop, token, firstFamily } of BODY_STOPS) {
96
+ it(`${stop}: text node is ${firstFamily} ${LABEL_SIZE_PX}px/${LABEL_LEADING_PX}px`, () => {
97
+ const m = measureLabel(stop, scheme);
98
+ expect(m.knobs.knobProps.body.fontFamily).toBe(`$${token}`);
99
+
100
+ // Trap: a wrapping frame stays Inter. We measured the text node.
101
+ expect([...m.host.childNodes].some((n) => n.nodeType === Node.TEXT_NODE)).toBe(true);
102
+
103
+ expect(m.firstFamily.toLowerCase()).toBe(firstFamily.toLowerCase());
104
+ // The Inter stack legitimately names -apple-system as a fallback;
105
+ // the defect was it arriving FIRST, which is what the text node reads.
106
+ expect(m.firstFamily.toLowerCase()).not.toBe("-apple-system");
107
+
108
+ expect(m.lineHeight.toLowerCase()).not.toBe("normal");
109
+ expect(m.lineHeightPx).toBe(LABEL_LEADING_PX);
110
+ expect(m.fontSize).toBe(LABEL_SIZE_PX);
111
+ expect(m.lineHeightPx / m.fontSize).toBeGreaterThanOrEqual(1.4);
112
+ });
113
+ }
114
+
115
+ it("the four stops resolve to four distinct family stacks", () => {
116
+ const families = BODY_STOPS.map((s) => measureLabel(s.stop, scheme).firstFamily);
117
+ expect(new Set(families).size).toBe(4);
118
+ expect(families.some((f) => f.toLowerCase() === "-apple-system")).toBe(false);
119
+ });
120
+
121
+ it("flipping the family knob moves the family and nothing else", () => {
122
+ const measured = BODY_STOPS.map((s) => measureLabel(s.stop, scheme));
123
+ const sizes = new Set(measured.map((m) => m.fontSize));
124
+ const leadings = new Set(measured.map((m) => m.lineHeightPx));
125
+ expect([...sizes]).toEqual([LABEL_SIZE_PX]);
126
+ expect([...leadings]).toEqual([LABEL_LEADING_PX]);
127
+ });
128
+
129
+ it("does not leak Inter's optical tracking onto the other faces (LC-11)", () => {
130
+ for (const { stop } of BODY_STOPS.filter((s) => s.stop !== "sans-serif")) {
131
+ const m = measureLabel(stop, scheme);
132
+ expect(Number.parseFloat(m.letterSpacing) || 0, `${stop} tracking`).toBe(0);
133
+ }
134
+ // Inter keeps its curve.
135
+ expect(Number.parseFloat(measureLabel("sans-serif", scheme).letterSpacing)).toBeLessThan(0);
136
+ });
137
+ });
138
+ }
139
+ });