@multiplatform.one/theme 7.5.0 → 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 (42) hide show
  1. package/package.json +5 -5
  2. package/src/audit/index.ts +19 -0
  3. package/src/audit/themeMatrix.spec.ts +274 -0
  4. package/src/audit/themeMatrix.ts +630 -0
  5. package/src/font.ts +36 -11
  6. package/src/index.ts +12 -0
  7. package/src/theme/FontKnobStyles.tsx +9 -28
  8. package/src/theme/colorRules.spec.ts +73 -0
  9. package/src/theme/cornerSmoothing.spec.ts +63 -6
  10. package/src/theme/cornerSmoothing.ts +15 -0
  11. package/src/theme/defaults/categoryFonts.ts +15 -12
  12. package/src/theme/defaults/fonts.spec.ts +106 -36
  13. package/src/theme/defaults/fonts.theme.spec.tsx +69 -58
  14. package/src/theme/defaults/fonts.ts +115 -79
  15. package/src/theme/focusState.spec.ts +17 -8
  16. package/src/theme/fontCategoryMetrics.ts +57 -0
  17. package/src/theme/fontCategoryStacks.ts +19 -6
  18. package/src/theme/index.ts +1 -0
  19. package/src/theme/resolveKnobs.spec.ts +16 -2
  20. package/src/theme/resolveKnobs.ts +3 -2
  21. package/types/audit/index.d.ts +2 -0
  22. package/types/audit/index.d.ts.map +1 -1
  23. package/types/audit/themeMatrix.d.ts +172 -0
  24. package/types/audit/themeMatrix.d.ts.map +1 -0
  25. package/types/font.d.ts.map +1 -1
  26. package/types/index.d.ts +2 -0
  27. package/types/index.d.ts.map +1 -1
  28. package/types/theme/FontKnobStyles.d.ts +6 -11
  29. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  30. package/types/theme/colorRules.d.ts +1 -1
  31. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  32. package/types/theme/defaults/categoryFonts.d.ts +4 -0
  33. package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
  34. package/types/theme/defaults/fonts.d.ts +34 -29
  35. package/types/theme/defaults/fonts.d.ts.map +1 -1
  36. package/types/theme/fontCategoryMetrics.d.ts +36 -0
  37. package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
  38. package/types/theme/fontCategoryStacks.d.ts +18 -5
  39. package/types/theme/fontCategoryStacks.d.ts.map +1 -1
  40. package/types/theme/index.d.ts +1 -0
  41. package/types/theme/index.d.ts.map +1 -1
  42. package/types/theme/resolveKnobs.d.ts.map +1 -1
@@ -1,10 +1,21 @@
1
1
  /**
2
2
  * @vitest-environment happy-dom
3
3
  *
4
- * Theme spec (MPO-44): bodyFont family + leading measured on the TEXT NODE.
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
+ *
5
9
  * 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.
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.
8
19
  */
9
20
 
10
21
  import { createElement } from "react";
@@ -17,10 +28,10 @@ import { TamaguiProvider, Text } from "tamagui";
17
28
  import { createDefaultThemeConfig } from "../createDefaultThemeConfig";
18
29
  import { defaultKnobs } from "../knobs";
19
30
  import { resolveKnobs } from "../resolveKnobs";
20
- import { fontFamilyStacks } from "./fonts";
21
31
 
22
32
  const themeConfig = createDefaultThemeConfig();
23
33
 
34
+ /** The stops the button-knobs board shoots, in board order. */
24
35
  const BODY_STOPS = [
25
36
  { stop: "sans-serif", token: "body", firstFamily: "Inter" },
26
37
  { stop: "serif", token: "serif", firstFamily: "Georgia" },
@@ -28,6 +39,10 @@ const BODY_STOPS = [
28
39
  { stop: "rounded", token: "rounded", firstFamily: "Nunito" },
29
40
  ] as const;
30
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
+
31
46
  afterEach(cleanup);
32
47
 
33
48
  /** Parent of the text node whose data is `label` — never a wrapping frame. */
@@ -50,78 +65,74 @@ function measureLabel(bodyFont: (typeof BODY_STOPS)[number]["stop"], scheme: "li
50
65
  createElement(
51
66
  TamaguiProvider,
52
67
  { config: themeConfig.tamagui, defaultTheme: scheme },
53
- createElement(
54
- Text,
55
- { ...knobs.knobProps.body, fontSize: "$true", "data-testid": "body-label" },
56
- label,
57
- ),
68
+ createElement(Text, { ...knobs.knobProps.body, fontSize: "$true" }, label),
58
69
  ),
59
70
  );
60
71
  const host = textNodeHost(container, label);
61
72
  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;
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
+ };
89
90
  }
90
91
 
91
- describe("bodyFont text-node family and leading (MPO-44)", () => {
92
+ describe("bodyFont text-node family, size and leading (MPO-44)", () => {
92
93
  for (const scheme of ["light", "dark"] as const) {
93
94
  describe(scheme, () => {
94
95
  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}`);
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}`);
98
99
 
99
100
  // 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
+ expect([...m.host.childNodes].some((n) => n.nodeType === Node.TEXT_NODE)).toBe(true);
101
102
 
102
- const family = specifiedFamily(host);
103
- const lineHeight = specifiedLineHeight(host);
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");
104
107
 
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);
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);
115
112
  });
116
113
  }
117
114
 
118
115
  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
- });
116
+ const families = BODY_STOPS.map((s) => measureLabel(s.stop, scheme).firstFamily);
123
117
  expect(new Set(families).size).toBe(4);
124
- expect(families.some((f) => f?.toLowerCase() === "-apple-system")).toBe(false);
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);
125
136
  });
126
137
  });
127
138
  }
@@ -1,6 +1,12 @@
1
1
  import { defaultConfig } from "@tamagui/config/v5";
2
2
  import { createInterFont } from "@tamagui/font-inter";
3
- import type { GenericFont } from "tamagui";
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";
4
10
 
5
11
  // Default purpose fonts for components that reference $heading / $body
6
12
  // directly, both backed by Inter.
@@ -27,14 +33,44 @@ import type { GenericFont } from "tamagui";
27
33
  // increasingly tight at display sizes (the same optical behavior Apple's SF
28
34
  // implements per point size). The curve is Inter's own dynamic-metrics
29
35
  // model (rsms.me/inter/dynmetrics): tracking(em) = a + b·e^(c·size).
30
- // These values are Inter-specific: when the headingFont/bodyFont knob picks
31
- // another family, FontKnobStyles' per-category metrics (LC-11) neutralize
32
- // this table for fixed-pitch and connected-script categories.
33
36
  //
34
- // Category fonts (serif/mono/rounded/…) MUST be registered on this same
35
- // size+10 ramp, including the `true` key. An unregistered `$serif` token
36
- // collapses the label TEXT NODE to -apple-system 14px/normal — the frame
37
- // stays Inter, so measuring the frame hides the defect (MPO-44).
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.
38
74
 
39
75
  const TRACKING_A = -0.0223;
40
76
  const TRACKING_B = 0.185;
@@ -50,39 +86,16 @@ export function interTrackingPx(sizePx: number): number {
50
86
  return Math.round(interTrackingEm(sizePx) * sizePx * 100) / 100;
51
87
  }
52
88
 
53
- /** Body leading stays on the ramp: size + 10 (14 → 24). Never CSS `normal`. */
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
+ */
54
93
  export const BODY_LINE_HEIGHT_OFFSET = 10;
55
94
 
56
95
  export function bodyLineHeightPx(sizePx: number): number {
57
96
  return sizePx + BODY_LINE_HEIGHT_OFFSET;
58
97
  }
59
98
 
60
- // Mirrors @tamagui/font-inter's default size table (pinned 2.0.0-rc.41) so
61
- // tracking can be computed per size key. The fonts.spec.ts invariant test
62
- // (letterSpacing[k] === interTrackingPx(size[k]) on the BUILT font) catches
63
- // any upstream drift. `true` is the default size token — omit it and
64
- // fontFamily="$serif" falls back to the system face with line-height:normal.
65
- // Category fonts (not heading/body) stay on this transcribed ramp.
66
- export const bodyFontSizes: Record<string, number> = {
67
- 1: 11,
68
- 2: 12,
69
- 3: 13,
70
- 4: 14,
71
- true: 14,
72
- 5: 16,
73
- 6: 18,
74
- 7: 20,
75
- 8: 23,
76
- 9: 30,
77
- 10: 46,
78
- 11: 55,
79
- 12: 62,
80
- 13: 72,
81
- 14: 92,
82
- 15: 114,
83
- 16: 134,
84
- };
85
-
86
99
  /**
87
100
  * A tamagui font table with Variables unwrapped and `$` prefixes stripped.
88
101
  * Upstream hands these back either raw or wrapped depending on where in the
@@ -108,11 +121,29 @@ function trackingFor(sizes: Record<string, number>): Record<string, number> {
108
121
  return Object.fromEntries(Object.entries(sizes).map(([key, px]) => [key, interTrackingPx(px)]));
109
122
  }
110
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
+
111
129
  const v5Body = defaultConfig.fonts.body;
112
130
  const v5Heading = defaultConfig.fonts.heading;
113
131
  const bodySizes = numbers(v5Body.size);
132
+ const bodyLineHeights = numbers(v5Body.lineHeight);
133
+ const bodyWeights = plain<string>(v5Body.weight);
114
134
  const headingSizes = numbers(v5Heading.size);
115
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
+
116
147
  /**
117
148
  * Inter faces the workspace ships. v5 heading weights are sparse — 600 up to
118
149
  * $5, 700 at $6–$8, 800 from $9 — and A4's reading of that is the point:
@@ -127,46 +158,45 @@ const interFaces = {
127
158
  } as const;
128
159
 
129
160
  /**
130
- * Concrete family stacks for each `headingFont`/`bodyFont` category.
131
- * Distinct first families so a text-node measurement cannot collapse three
132
- * stops onto one system fallback. Matches `packages/config/fonts.ts`
133
- * (serif/mono are explicit system aliases; the rest are shipped webfonts).
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.
134
164
  */
135
- export const fontFamilyStacks = {
136
- "sans-serif":
137
- 'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
138
- serif: "Georgia, 'Times New Roman', Times, serif",
139
- mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
140
- slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
141
- rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
142
- condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
143
- cursive: "'Dancing Script', 'Pacifico', cursive",
144
- handwriting: "'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive",
145
- pixel: "'Silkscreen', 'Press Start 2P', monospace",
146
- blackletter: "'UnifrakturCook', 'Fraktur', serif",
147
- geometric: "'Futura', 'Poppins', 'Century Gothic', sans-serif",
148
- } as const;
165
+ export const fontFamilyStacks = fontCategoryStacks;
149
166
 
150
- export type FontFamilyCategory = keyof typeof fontFamilyStacks;
167
+ export type FontFamilyCategory = FontCategory;
151
168
 
152
169
  /**
153
- * Category font on the body ramp: same size table as Inter (including
154
- * `true: 14`) and lineHeight size+10. Inter tracking is NOT copied — that
155
- * curve is Inter-specific (LC-11); FontKnobStyles applies per-category
156
- * floors when the knob restyles `$heading`/`$body`.
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.
157
173
  */
158
- export function createCategoryFont(family: string): GenericFont {
159
- return createInterFont(
160
- {
161
- family,
162
- letterSpacing: {},
163
- },
164
- {
165
- sizeLineHeight: bodyLineHeightPx,
166
- },
174
+ export function categoryLineHeights(category: FontCategory): Record<string, number> {
175
+ const minRatio = (fontCategoryMetrics[category] ?? fontCategoryMetrics["sans-serif"]).body;
176
+ return Object.fromEntries(
177
+ Object.entries(bodySizes).map(([key, px]) => [
178
+ key,
179
+ flooredLineHeight(px, bodyLineHeights[key] ?? bodyLineHeightPx(px), minRatio),
180
+ ]),
167
181
  );
168
182
  }
169
183
 
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
+ }
199
+
170
200
  export const defaultHeadingFont = createInterFont(
171
201
  {
172
202
  face: interFaces,
@@ -182,23 +212,29 @@ export const defaultBodyFont = createInterFont(
182
212
  {
183
213
  face: interFaces,
184
214
  size: bodySizes,
185
- lineHeight: numbers(v5Body.lineHeight),
186
- weight: plain<string>(v5Body.weight),
215
+ lineHeight: bodyLineHeights,
216
+ weight: bodyWeights,
187
217
  letterSpacing: trackingFor(bodySizes),
188
218
  },
189
219
  { sizeSize: (size) => size },
190
220
  );
191
221
 
192
- export const defaultSerifFont = createCategoryFont(fontFamilyStacks.serif);
193
- export const defaultMonoFont = createCategoryFont(fontFamilyStacks.mono);
194
- export const defaultSlabFont = createCategoryFont(fontFamilyStacks.slab);
195
- export const defaultRoundedFont = createCategoryFont(fontFamilyStacks.rounded);
196
- export const defaultCondensedFont = createCategoryFont(fontFamilyStacks.condensed);
197
- export const defaultCursiveFont = createCategoryFont(fontFamilyStacks.cursive);
198
- export const defaultHandwritingFont = createCategoryFont(fontFamilyStacks.handwriting);
199
- export const defaultPixelFont = createCategoryFont(fontFamilyStacks.pixel);
200
- export const defaultBlackletterFont = createCategoryFont(fontFamilyStacks.blackletter);
201
- export const defaultGeometricFont = createCategoryFont(fontFamilyStacks.geometric);
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);
202
238
 
203
239
  /** All purpose + category fonts registered by `createDefaultThemeConfig`. */
204
240
  export const defaultFonts = {
@@ -136,11 +136,22 @@ describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
136
136
  }
137
137
 
138
138
  it("the retired flat inset failed the floor over the solid intent fills (why offset is 0)", () => {
139
- // The measured failures that forced the flip (MPO-41 / MPO-19 X7): a −2
140
- // band paints INSIDE the control, so it had to contrast against the solid
141
- // intent fill — light accent 1.27:1, light error 1.17:1, dark error
142
- // 1.31:1. Locking these <3:1 keeps the offset-0 geometry load-bearing: if
143
- // the fills ever lighten past the floor this test goes stale, not wrong.
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.
144
155
  for (const [scheme, intent] of [
145
156
  ["light", "accent"],
146
157
  ["light", "error"],
@@ -196,9 +207,7 @@ describe("LC-71: keyboard-only; measure the ring / text node, not the frame", ()
196
207
  });
197
208
 
198
209
  it("chip dismiss rings the glyph node, not the 44px hit-box frame", () => {
199
- const frameRule = compositeFocusRingCss.match(
200
- /\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/,
201
- );
210
+ const frameRule = compositeFocusRingCss.match(/\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/);
202
211
  const ringRule = compositeFocusRingCss.match(
203
212
  /\.mp-chip-dismiss:focus-visible \.mp-chip-dismiss-ring\s*\{([^}]+)\}/,
204
213
  );
@@ -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
+ }
@@ -1,18 +1,31 @@
1
1
  import type { FontCategory } from "./knobs";
2
2
 
3
3
  /**
4
- * Concrete stacks for each headingFont/bodyFont category. Used as the
5
- * FontKnobStyles `--f-family` fallback when Tamagui has no `$serif` (etc.)
6
- * font registered, and as the default category fonts on
7
- * createDefaultThemeConfig so consumers do not have to pass a font map.
4
+ * The one concrete family stack per headingFont/bodyFont category.
8
5
  *
9
- * Matches packages/config/fonts.ts (system serif/mono; webfonts for the rest).
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.
10
23
  */
11
24
  export const fontCategoryStacks: Record<FontCategory, string> = {
12
25
  "sans-serif":
13
26
  'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
14
27
  serif: "Georgia, 'Times New Roman', Times, serif",
15
- mono: "monospace",
28
+ mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
16
29
  slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
17
30
  rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
18
31
  condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
@@ -9,6 +9,7 @@ export * from "./defaults/index";
9
9
  export * from "./devtools/ThemeDevtoolsPanel";
10
10
  export * from "./focusState";
11
11
  export * from "./glyphPaint";
12
+ export * from "./fontCategoryMetrics";
12
13
  export * from "./fontCategoryStacks";
13
14
  export * from "./FontKnobStyles";
14
15
  export * from "./groupGeometry";
@@ -1,3 +1,4 @@
1
+ import { tokens } from "@tamagui/themes";
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import {
3
4
  defaultKnobs,
@@ -580,8 +581,7 @@ describe("resolveKnobs", () => {
580
581
  ).toBe("$6");
581
582
  });
582
583
 
583
- it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", async () => {
584
- const { tokens } = await import("@tamagui/themes");
584
+ it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", () => {
585
585
  const radiusTokenByKnob = {
586
586
  none: "$0",
587
587
  small: "$2",
@@ -1133,6 +1133,20 @@ describe("applyDensity", () => {
1133
1133
  expect(compact.knobProps.control.paddingHorizontal).toBe(16);
1134
1134
  });
1135
1135
 
1136
+ it("names which gap density moves: the LAYOUT gap, never the recipe gap (LC-79)", () => {
1137
+ // `control.gap` tracks the RADIUS knob, so it is invariant under density
1138
+ // by construction. The gap a reader sees tighten is `knobProps.gap`, the
1139
+ // space-ramp one, and it only moves once `applyDensity` has stepped
1140
+ // `space` — `resolveKnobs` alone is a pure token mapper.
1141
+ const comfortable = resolveKnobs(applyDensity(knobsWith({ space: "medium" })));
1142
+ const compact = resolveKnobs(applyDensity(knobsWith({ space: "medium", density: "compact" })));
1143
+ expect(compact.knobProps.control.gap).toBe(comfortable.knobProps.control.gap);
1144
+ expect(compact.knobProps.gap).not.toEqual(comfortable.knobProps.gap);
1145
+ expect(compact.knobProps.panelPadding).not.toEqual(comfortable.knobProps.panelPadding);
1146
+ expect(compact.knobProps.control.height).toBe(44);
1147
+ expect(comfortable.knobProps.control.height).toBe(44);
1148
+ });
1149
+
1136
1150
  it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
1137
1151
  const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
1138
1152
  const compact = resolveKnobs(
@@ -97,8 +97,9 @@ const borderWidthMap = {
97
97
  // ── Corner smoothing (Axiom 15 OPTICS) ────────────────────────
98
98
  // `smooth` upgrades circular-arc corners to continuous curvature —
99
99
  // `corner-shape: squircle` (≡ superellipse(2), ≈ Apple / Figma 0.6) — as a
100
- // web progressive enhancement: Chromium 139+ paints it, every other engine
101
- // drops the unknown declaration and keeps the plain border-radius arc.
100
+ // web progressive enhancement: Chromium paints it (measured from 143; WebKit
101
+ // 26 and Firefox 148 drop the unknown declaration and keep the plain
102
+ // border-radius arc — docs/design/boards/W1-corner-smoothing/).
102
103
  // Delivery is a class + mounted stylesheet (cornerSmoothing.ts) because
103
104
  // Tamagui strips unknown keys from the `style` prop (skipProps) but merges
104
105
  // `className`. Native resolves to `round` for now (SquircleView adoption is
@@ -2,4 +2,6 @@ export type { AuditReport, AuditSummary, ElementAudit, PropertyAudit, TextProper
2
2
  export { runConstraintAudit } from "./constraintAudit";
3
3
  export type { AuditDiff, ElementDiff, PropertyDiff } from "./diffReport";
4
4
  export { diffAuditReports, formatAuditReport } from "./diffReport";
5
+ export type { GeometryDiff, GeometryMove, MatrixElementGeometry, MatrixRect, MatrixSnapshot, MatrixTextSample, NoBreakageInput, NoBreakageResult, TextContrastResult, TextContrastViolation, } from "./themeMatrix";
6
+ export { auditSignatures, captureMatrixSnapshot, diffGeometry, evaluateNoBreakage, evaluateTextContrast, } from "./themeMatrix";
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/audit/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/audit/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACnE,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,qBAAqB,EACrB,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,YAAY,EACZ,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,eAAe,CAAC"}