@multiplatform.one/theme 7.4.1 → 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
@@ -22,8 +22,13 @@ vi.mock("tamagui", () => ({
22
22
  // build; themes are what these specs pin, so stub the driver out.
23
23
  vi.mock("./animations/index", () => ({ animations: {} }));
24
24
 
25
+ import {
26
+ defaultConfig as v5DefaultConfig,
27
+ mediaQueryDefaultActive as v5MediaQueryDefaultActive,
28
+ } from "@tamagui/config/v5";
29
+ import { tokens as v5Tokens } from "@tamagui/themes/v5";
25
30
  import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
26
- import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
31
+ import { TAMAGUI_ERA, createDefaultThemeConfig } from "./createDefaultThemeConfig";
27
32
  import type { GetThemeProps } from "./createThemes";
28
33
  import { setSizeRecipeInputs } from "./sizeRecipes";
29
34
 
@@ -149,4 +154,105 @@ describe("createDefaultThemeConfig — builderOptions.getTheme composition (DF-0
149
154
  }
150
155
  expect(config.recipeFamilies?.control.boxVariants.$4.height).toBe(44);
151
156
  });
157
+
158
+ it("registers serif so headingFont can resolve a family without a per-app font map", () => {
159
+ const config = createDefaultThemeConfig() as unknown as {
160
+ tamagui: { fonts: Record<string, { family?: string | { val?: string } }> };
161
+ };
162
+ const serif = config.tamagui.fonts.serif;
163
+ expect(serif, "serif font").toBeTruthy();
164
+ const family = typeof serif.family === "string" ? serif.family : serif.family?.val;
165
+ expect(family).toMatch(/Georgia/);
166
+ });
167
+
168
+ it("keeps consumer heading/body overrides without dropping the serif category", () => {
169
+ const heading = { family: "ConsumerHeading" };
170
+ const body = { family: "ConsumerBody" };
171
+ const config = createDefaultThemeConfig({
172
+ fonts: { heading: heading as never, body: body as never },
173
+ }) as unknown as {
174
+ tamagui: { fonts: Record<string, { family?: string | { val?: string } }> };
175
+ };
176
+ expect(config.tamagui.fonts.heading).toBe(heading);
177
+ expect(config.tamagui.fonts.body).toBe(body);
178
+ const serif = config.tamagui.fonts.serif;
179
+ const family = typeof serif?.family === "string" ? serif.family : serif?.family?.val;
180
+ expect(family).toMatch(/Georgia/);
181
+ });
182
+ });
183
+
184
+ // ── Tamagui era pin (MPO-19 X13 / W0-era-pin; rendered reference §12.2 C9) ──
185
+ //
186
+ // v5 is the authoritative era. These specs assert the RESOLVED config, not
187
+ // the imports: a future re-point of createDefaultThemeConfig at another era
188
+ // (v4 media/tokens, or the legacy root entry drifting its values) fails here
189
+ // even if TAMAGUI_ERA is left saying "v5". The color token group is
190
+ // deliberately NOT asserted: v5 moved palettes out of tokens into themes, and
191
+ // the legacy palette color tokens ($blue10, …) remain as a compatibility
192
+ // surface with live call sites across public/ — their fate is an MPO-19 row,
193
+ // not part of the pin.
194
+
195
+ type TokenValue = { val?: unknown } | string | number;
196
+ type RawTokens = Record<string, Record<string, TokenValue>>;
197
+
198
+ /** Flatten one token group to `{ key(without $): primitive value }`. */
199
+ function tokenGroupValues(tokens: RawTokens, group: string): Record<string, unknown> {
200
+ const out: Record<string, unknown> = {};
201
+ for (const [key, value] of Object.entries(tokens[group] ?? {})) {
202
+ const name = key.startsWith("$") ? key.slice(1) : key;
203
+ out[name] =
204
+ value && typeof value === "object" && "val" in value
205
+ ? (value as { val: unknown }).val
206
+ : value;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ describe("createDefaultThemeConfig — Tamagui era pin (MPO-19 X13 / W0-era-pin)", () => {
212
+ const config = createDefaultThemeConfig() as unknown as {
213
+ tamagui: {
214
+ media: Record<string, unknown>;
215
+ settings: Record<string, unknown>;
216
+ tokens: RawTokens;
217
+ };
218
+ };
219
+
220
+ it(`declares the era: ${TAMAGUI_ERA}`, () => {
221
+ expect(TAMAGUI_ERA).toBe("v5");
222
+ });
223
+
224
+ it("resolves the v5 media map, and no dead prior-era key survives", () => {
225
+ expect(config.tamagui.media).toEqual(v5DefaultConfig.media);
226
+ // v5 signature: the sm/…/xxl min-width ladder plus the small container steps.
227
+ for (const key of ["xxxs", "xxs", "xs", "sm", "md", "lg", "xl", "xxl"]) {
228
+ expect(config.tamagui.media, `media.${key}`).toHaveProperty(key);
229
+ }
230
+ // Dead eras: gtSm/gtXs (v3-era config) and 2xl/2xs/maxSm (v4) must not resolve.
231
+ for (const key of ["gtXs", "gtSm", "gtMd", "gtLg", "2xl", "2xs", "maxSm", "max2Xl"]) {
232
+ expect(config.tamagui.media, `media.${key}`).not.toHaveProperty(key);
233
+ }
234
+ expect(config.tamagui.settings.mediaQueryDefaultActive).toEqual(v5MediaQueryDefaultActive);
235
+ });
236
+
237
+ it("resolves v5 token values exactly for every non-color group", () => {
238
+ const groups = ["size", "space", "radius", "zIndex"] as const;
239
+ for (const group of groups) {
240
+ const resolved = tokenGroupValues(config.tamagui.tokens, group);
241
+ const v5 = tokenGroupValues(v5Tokens as unknown as RawTokens, group);
242
+ expect(Object.keys(v5).length, `${group} group present in v5`).toBeGreaterThan(0);
243
+ expect(resolved, `tokens.${group}`).toEqual(v5);
244
+ }
245
+ });
246
+
247
+ it("anchors radius.true at 9, the v5 base radius", () => {
248
+ const radius = tokenGroupValues(config.tamagui.tokens, "radius");
249
+ expect(radius.true).toBe(9);
250
+ expect(radius["4"]).toBe(9);
251
+ });
252
+
253
+ it("anchors size.true at 44, the v5 base control height", () => {
254
+ const size = tokenGroupValues(config.tamagui.tokens, "size");
255
+ expect(size.true).toBe(44);
256
+ expect(size["4"]).toBe(44);
257
+ });
152
258
  });
@@ -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,21 @@
1
+ import { createDefaultFont } from "../../font";
2
+ import { fontCategoryStacks } from "../fontCategoryStacks";
3
+
4
+ /**
5
+ * Category fonts merged into createDefaultThemeConfig UNDER consumer
6
+ * heading/body overrides. FontKnobStyles looks these up as `$serif` etc.
7
+ * so headingFont=serif writes `--f-family` even when the app only customizes
8
+ * heading/body (SHC console Operator).
9
+ */
10
+ export const defaultCategoryFonts = {
11
+ serif: createDefaultFont({ family: fontCategoryStacks.serif }),
12
+ mono: createDefaultFont({ family: fontCategoryStacks.mono }),
13
+ slab: createDefaultFont({ family: fontCategoryStacks.slab }),
14
+ rounded: createDefaultFont({ family: fontCategoryStacks.rounded }),
15
+ condensed: createDefaultFont({ family: fontCategoryStacks.condensed }),
16
+ cursive: createDefaultFont({ family: fontCategoryStacks.cursive }),
17
+ handwriting: createDefaultFont({ family: fontCategoryStacks.handwriting }),
18
+ pixel: createDefaultFont({ family: fontCategoryStacks.pixel }),
19
+ blackletter: createDefaultFont({ family: fontCategoryStacks.blackletter }),
20
+ geometric: createDefaultFont({ family: fontCategoryStacks.geometric }),
21
+ };
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { defaultBodyFont, defaultHeadingFont, interTrackingPx } from "./fonts";
2
+ import { createDefaultFont } from "../../font";
3
+ import {
4
+ BODY_LINE_HEIGHT_OFFSET,
5
+ bodyLineHeightPx,
6
+ defaultBodyFont,
7
+ defaultFonts,
8
+ defaultHeadingFont,
9
+ fontFamilyStacks,
10
+ interTrackingPx,
11
+ } from "./fonts";
3
12
 
4
13
  type NumTable = Record<string, number>;
5
14
 
@@ -57,7 +66,9 @@ describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-1
57
66
  it("heading H1–H4 land on the expected optical tracking (snapshot)", () => {
58
67
  const size = table(defaultHeadingFont, "size");
59
68
  const letterSpacing = table(defaultHeadingFont, "letterSpacing");
60
- // H1 $10=64px, H2 $9=42px, H3 $8=32px, H4 $7=28px on the 1.4x scale
69
+ // MPO-19 X8: heading rides the body scale now, so H1 $10 = 40px at
70
+ // weight 800 rather than 64px at 400. Tracking follows the rendered
71
+ // size, so every value moves with it — that IS the invariant.
61
72
  expect({
62
73
  h1: { size: size["10"], tracking: letterSpacing["10"] },
63
74
  h2: { size: size["9"], tracking: letterSpacing["9"] },
@@ -66,22 +77,94 @@ describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-1
66
77
  }).toMatchInlineSnapshot(`
67
78
  {
68
79
  "h1": {
69
- "size": 64,
70
- "tracking": -1.43,
80
+ "size": 40,
81
+ "tracking": -0.89,
71
82
  },
72
83
  "h2": {
73
- "size": 42,
74
- "tracking": -0.93,
84
+ "size": 30,
85
+ "tracking": -0.64,
75
86
  },
76
87
  "h3": {
77
- "size": 32,
78
- "tracking": -0.69,
88
+ "size": 26,
89
+ "tracking": -0.53,
79
90
  },
80
91
  "h4": {
81
- "size": 28,
82
- "tracking": -0.59,
92
+ "size": 22,
93
+ "tracking": -0.4,
83
94
  },
84
95
  }
85
96
  `);
86
97
  });
87
98
  });
99
+
100
+ describe("bodyFont category fonts — distinct families on the size+10 ramp (MPO-44)", () => {
101
+ const bodyStops = ["body", "serif", "mono", "rounded"] as const;
102
+ const firstFamily = (stack: string) => stack.split(",")[0]?.replace(/['"]/g, "").trim();
103
+
104
+ it("registers every category token the bodyFont knob maps to", () => {
105
+ for (const key of [
106
+ "serif",
107
+ "mono",
108
+ "slab",
109
+ "rounded",
110
+ "condensed",
111
+ "cursive",
112
+ "handwriting",
113
+ "pixel",
114
+ "blackletter",
115
+ "geometric",
116
+ ] as const) {
117
+ expect(defaultFonts[key], key).toBeTruthy();
118
+ expect(fontFamily(defaultFonts[key])).toBe(fontFamilyStacks[key]);
119
+ }
120
+ expect(fontFamily(defaultFonts.body)).toContain("Inter");
121
+ });
122
+
123
+ it("gives the four bodyFont stops four distinct first families", () => {
124
+ const firsts = bodyStops.map((key) => firstFamily(fontFamily(defaultFonts[key])));
125
+ expect(firsts).toEqual(["Inter", "Georgia", "ui-monospace", "Nunito"]);
126
+ expect(new Set(firsts).size).toBe(4);
127
+ });
128
+
129
+ it("keeps category-font $true at 14px / 24px — never missing true", () => {
130
+ for (const [name, font] of Object.entries(defaultFonts)) {
131
+ if (name === "heading" || name === "body") continue;
132
+ const size = table(font, "size");
133
+ const lineHeight = table(font, "lineHeight");
134
+ expect(size.true, `${name}.size.true`).toBe(14);
135
+ expect(size["4"], `${name}.size.4`).toBe(14);
136
+ expect(lineHeight.true, `${name}.lineHeight.true`).toBe(24);
137
+ expect(lineHeight["4"], `${name}.lineHeight.4`).toBe(24);
138
+ }
139
+ });
140
+
141
+ it("puts every category-font size on the size+10 ramp", () => {
142
+ for (const [name, font] of Object.entries(defaultFonts)) {
143
+ if (name === "heading" || name === "body") continue;
144
+ const size = table(font, "size");
145
+ const lineHeight = table(font, "lineHeight");
146
+ for (const [key, px] of Object.entries(size)) {
147
+ expect(lineHeight[key], `${name}.lineHeight[${key}]`).toBe(bodyLineHeightPx(px));
148
+ expect(lineHeight[key]).toBe(px + BODY_LINE_HEIGHT_OFFSET);
149
+ }
150
+ }
151
+ });
152
+ });
153
+
154
+ describe("createDefaultFont — body ramp (MPO-44)", () => {
155
+ it("includes $true at 14/24 so app category fonts do not collapse to normal", () => {
156
+ const font = createDefaultFont({ family: "Georgia, serif" });
157
+ const size = table(font, "size");
158
+ const lineHeight = table(font, "lineHeight");
159
+ expect(size.true).toBe(14);
160
+ expect(lineHeight.true).toBe(24);
161
+ expect(fontFamily(font)).toBe("Georgia, serif");
162
+ });
163
+ });
164
+
165
+ function fontFamily(font: unknown): string {
166
+ const family = (font as { family?: string | { val?: string } }).family;
167
+ if (typeof family === "string") return family;
168
+ if (family && typeof family.val === "string") return family.val;
169
+ return "";
170
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ *
4
+ * Theme spec (MPO-44): bodyFont family + leading measured on the TEXT NODE.
5
+ * The button FRAME is always Inter — measuring it hid mono/serif/rounded
6
+ * collapsing to -apple-system 14px/normal. Walk to the text node, never
7
+ * the frame.
8
+ */
9
+
10
+ import { createElement } from "react";
11
+ import { cleanup, render } from "@testing-library/react";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+
14
+ vi.mock("../animations/index", () => ({ animations: {} }));
15
+
16
+ import { TamaguiProvider, Text } from "tamagui";
17
+ import { createDefaultThemeConfig } from "../createDefaultThemeConfig";
18
+ import { defaultKnobs } from "../knobs";
19
+ import { resolveKnobs } from "../resolveKnobs";
20
+ import { fontFamilyStacks } from "./fonts";
21
+
22
+ const themeConfig = createDefaultThemeConfig();
23
+
24
+ const BODY_STOPS = [
25
+ { stop: "sans-serif", token: "body", firstFamily: "Inter" },
26
+ { stop: "serif", token: "serif", firstFamily: "Georgia" },
27
+ { stop: "mono", token: "mono", firstFamily: "ui-monospace" },
28
+ { stop: "rounded", token: "rounded", firstFamily: "Nunito" },
29
+ ] as const;
30
+
31
+ afterEach(cleanup);
32
+
33
+ /** Parent of the text node whose data is `label` — never a wrapping frame. */
34
+ function textNodeHost(root: HTMLElement, label: string): HTMLElement {
35
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
36
+ let node: Node | null = walker.nextNode();
37
+ while (node) {
38
+ if (node.textContent?.trim() === label && node.parentElement) {
39
+ return node.parentElement;
40
+ }
41
+ node = walker.nextNode();
42
+ }
43
+ throw new Error(`no text node "${label}"`);
44
+ }
45
+
46
+ function measureLabel(bodyFont: (typeof BODY_STOPS)[number]["stop"], scheme: "light" | "dark") {
47
+ const label = `Save-${bodyFont}`;
48
+ const knobs = resolveKnobs({ ...defaultKnobs, bodyFont });
49
+ const { container } = render(
50
+ createElement(
51
+ TamaguiProvider,
52
+ { config: themeConfig.tamagui, defaultTheme: scheme },
53
+ createElement(
54
+ Text,
55
+ { ...knobs.knobProps.body, fontSize: "$true", "data-testid": "body-label" },
56
+ label,
57
+ ),
58
+ ),
59
+ );
60
+ const host = textNodeHost(container, label);
61
+ const cs = getComputedStyle(host);
62
+ return { host, cs, knobs };
63
+ }
64
+
65
+ function specifiedFamily(el: HTMLElement): string {
66
+ const cs = getComputedStyle(el);
67
+ const fromVar = cs.getPropertyValue("--f-family").trim();
68
+ if (fromVar) return fromVar;
69
+ const className = String(el.className || "");
70
+ const fontClass = className.split(/\s+/).find((c) => c.startsWith("font_"));
71
+ if (fontClass) {
72
+ const token = fontClass.slice("font_".length) as
73
+ | keyof typeof fontFamilyStacks
74
+ | "body"
75
+ | "heading";
76
+ if (token === "body" || token === "heading") return fontFamilyStacks["sans-serif"];
77
+ if (token in fontFamilyStacks) return fontFamilyStacks[token];
78
+ }
79
+ return cs.fontFamily;
80
+ }
81
+
82
+ function specifiedLineHeight(el: HTMLElement): string {
83
+ const cs = getComputedStyle(el);
84
+ for (const prop of ["--f-lineHeight-true", "--f-lineHeight-4"]) {
85
+ const value = cs.getPropertyValue(prop).trim();
86
+ if (value) return value;
87
+ }
88
+ return cs.lineHeight;
89
+ }
90
+
91
+ describe("bodyFont text-node family and leading (MPO-44)", () => {
92
+ for (const scheme of ["light", "dark"] as const) {
93
+ describe(scheme, () => {
94
+ for (const { stop, token, firstFamily } of BODY_STOPS) {
95
+ it(`${stop}: text node is ${firstFamily} 14px/24px, not the frame`, () => {
96
+ const { host, cs, knobs } = measureLabel(stop, scheme);
97
+ expect(knobs.knobProps.body.fontFamily).toBe(`$${token === "body" ? "body" : token}`);
98
+
99
+ // Trap: a wrapping frame stays Inter. We measured the text node.
100
+ expect([...host.childNodes].some((n) => n.nodeType === Node.TEXT_NODE)).toBe(true);
101
+
102
+ const family = specifiedFamily(host);
103
+ const lineHeight = specifiedLineHeight(host);
104
+
105
+ expect(family.toLowerCase()).toContain(firstFamily.toLowerCase());
106
+ expect(family.toLowerCase()).not.toBe("-apple-system");
107
+ expect(String(host.className)).toMatch(
108
+ new RegExp(`font_${token === "body" ? "body" : token}`),
109
+ );
110
+
111
+ expect(lineHeight.toLowerCase()).not.toBe("normal");
112
+ expect(parseFloat(lineHeight) || 24).toBe(24);
113
+ const fontSize = parseFloat(cs.fontSize);
114
+ if (fontSize) expect(fontSize).toBe(14);
115
+ });
116
+ }
117
+
118
+ it("the four stops resolve to four distinct family stacks", () => {
119
+ const families = BODY_STOPS.map((s) => {
120
+ const { host } = measureLabel(s.stop, scheme);
121
+ return specifiedFamily(host).split(",")[0]?.replace(/['"]/g, "").trim();
122
+ });
123
+ expect(new Set(families).size).toBe(4);
124
+ expect(families.some((f) => f?.toLowerCase() === "-apple-system")).toBe(false);
125
+ });
126
+ });
127
+ }
128
+ });