@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
package/src/index.ts CHANGED
@@ -7,5 +7,17 @@ export * from "./menuRow";
7
7
  export * from "./font";
8
8
  export * from "./fonts/index";
9
9
 
10
+ // Contrast + matrix assertions downstream repos call (MPO-18). The full
11
+ // audit surface including the browser-side capturer stays on ./audit.
12
+ export { diffGeometry, evaluateNoBreakage, evaluateTextContrast } from "./audit";
13
+ export type {
14
+ GeometryDiff,
15
+ MatrixTextSample,
16
+ NoBreakageInput,
17
+ NoBreakageResult,
18
+ TextContrastResult,
19
+ TextContrastViolation,
20
+ } from "./audit";
21
+
10
22
  // Thin type re-exports so packages/apps never import @tamagui/web directly.
11
23
  export type { GenericFonts, MediaQueryKey, ThemeName } from "@tamagui/web";
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { getConfig } from "@tamagui/web";
29
29
  import { useEffect } from "react";
30
+ import { fontCategoryMetrics, flooredLineHeight } from "./fontCategoryMetrics";
30
31
  import { fontCategoryStacks } from "./fontCategoryStacks";
31
32
  import type { FontCategory } from "./knobs";
32
33
  import { defaultKnobs } from "./knobs";
@@ -35,32 +36,12 @@ import { usePresetContext } from "./PresetContext";
35
36
  const STYLE_TAG_ID = "mp-font-knob-styles";
36
37
 
37
38
  /**
38
- * Per-category legibility floors, applied on top of the base (Inter-tuned)
39
- * lineHeight tables when the heading/body knob selects that category.
40
- * `heading`/`body` are minimum lineHeight:fontSize ratios; `letterSpacing:
41
- * "neutral"` zeroes Inter's tracking-by-size curve (wrong for fixed-pitch
42
- * and connected-script families). "sans-serif" is the default scope (never
43
- * overridden), listed for completeness.
39
+ * Re-exported so `@multiplatform.one/theme` consumers keep the historical
40
+ * import path. The table itself lives in `fontCategoryMetrics.ts` because
41
+ * `defaults/fonts.ts` bakes the same floors into the registered category
42
+ * fonts and the two must not drift (LC-11).
44
43
  */
45
- export const fontCategoryMetrics: Record<
46
- FontCategory,
47
- { heading: number; body: number; letterSpacing: "inherit" | "neutral" }
48
- > = {
49
- "sans-serif": { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
50
- serif: { heading: 1.2, body: 1.4, letterSpacing: "inherit" },
51
- mono: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
52
- // Heading floors below are sized to each configured face's em box
53
- // (ascent+descent) so display headings never clip (LC-11): Roboto Slab
54
- // ≈1.32, Nunito ≈1.36, Silkscreen ≈1.3, Poppins/Futura ≈1.4.
55
- slab: { heading: 1.35, body: 1.4, letterSpacing: "inherit" },
56
- rounded: { heading: 1.4, body: 1.4, letterSpacing: "inherit" },
57
- condensed: { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
58
- cursive: { heading: 1.3, body: 1.5, letterSpacing: "neutral" },
59
- handwriting: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
60
- pixel: { heading: 1.35, body: 1.4, letterSpacing: "neutral" },
61
- blackletter: { heading: 1.3, body: 1.45, letterSpacing: "neutral" },
62
- geometric: { heading: 1.45, body: 1.4, letterSpacing: "inherit" },
63
- };
44
+ export { fontCategoryMetrics };
64
45
 
65
46
  // Default leading for font-scoped text that carries no explicit lineHeight
66
47
  // (e.g. `Text fontFamily="$body" fontSize=…` renders `line-height: normal`
@@ -166,9 +147,9 @@ function buildScopeRule(
166
147
  for (const [key, size] of Object.entries(tables.size)) {
167
148
  const baseLineHeight = tables.lineHeight[key];
168
149
  if (typeof baseLineHeight !== "number") continue;
169
- const floor = Math.round(size * minRatio);
170
- if (baseLineHeight < floor) {
171
- decls.push(`--f-lineHeight-${key}: ${floor}px !important`);
150
+ const floored = flooredLineHeight(size, baseLineHeight, minRatio);
151
+ if (floored > baseLineHeight) {
152
+ decls.push(`--f-lineHeight-${key}: ${floored}px !important`);
172
153
  }
173
154
  }
174
155
  if (metrics.letterSpacing === "neutral" && tables.letterSpacing) {
@@ -424,3 +424,76 @@ describe("contrast floor on built themes (DG-A11Y-01 / DG-COL-01)", () => {
424
424
  ).toThrow(/light_accent: color on background: #f9f8fc on #eeecf9 = 1.1:1/);
425
425
  });
426
426
  });
427
+
428
+ describe("intent color11 on the base page surfaces (MPO-40)", () => {
429
+ const themes = createThemesBuilder(
430
+ defaultBaseTheme,
431
+ defaultAccentTheme,
432
+ defaultBuilderOptions,
433
+ ).themes() as Record<string, Record<string, string>>;
434
+
435
+ const schemes = ["light", "dark"] as const;
436
+ const intents = ["error", "warning", "success"] as const;
437
+ // The surfaces an outlined control's TRANSPARENT frame can sit on. The
438
+ // intent sub-theme supplies the ink; the page behind it supplies the
439
+ // ground, so the pair is cross-theme and `resolveThemeContrast` (one
440
+ // theme) cannot address it.
441
+ const surfaces = ["background", "color1", "color2"] as const;
442
+
443
+ const intentInkPairs = schemes.flatMap((scheme) =>
444
+ intents.flatMap((intent) =>
445
+ surfaces.map((surface) => ({
446
+ foreground: themes[`${scheme}_${intent}`].color11,
447
+ background: themes[scheme][surface],
448
+ label: `${scheme}_${intent}: color11 on ${scheme}.${surface}`,
449
+ })),
450
+ ),
451
+ );
452
+
453
+ /**
454
+ * MPO-40 measured the outlined intent label on the gallery harness ground
455
+ * and recorded warning 4.34:1 and success 4.48:1. That ground is the BASE
456
+ * theme's `color2` (#f9f9fa) — NOT `background`/`color1`, which are both
457
+ * #ffffff and where the same two pairs clear the floor at 4.57 and 4.72.
458
+ * The miss is therefore surface-specific: an outlined warning or success
459
+ * Button only goes unreadable once it sits on a color2 surface (a card, a
460
+ * striped row, the gallery ground). Measuring against the plain page is
461
+ * exactly how a check reports a pass on a control that is failing.
462
+ *
463
+ * The two misses are PINNED to their measured ratios rather than skipped.
464
+ * Raise the $color11 step and the recorded ratio stops matching, which
465
+ * fails here and forces the entry out of this table — an exemption that
466
+ * retires itself instead of one that rots.
467
+ */
468
+ const pinnedMisses: Record<string, number> = {
469
+ "light_warning: color11 on light.color2": 4.34,
470
+ "light_success: color11 on light.color2": 4.48,
471
+ };
472
+
473
+ it("sweeps every intent x base surface x scheme — 18 pairs, none dropped", () => {
474
+ expect(intentInkPairs).toHaveLength(18);
475
+ });
476
+
477
+ it("clears AA everywhere except the two pinned MPO-40 misses", () => {
478
+ const misses = intentInkPairs
479
+ .map(measureContrast)
480
+ .filter((report) => !report.pass)
481
+ .map((report) => [report.label, report.ratio] as const);
482
+ expect(Object.fromEntries(misses)).toEqual(pinnedMisses);
483
+ });
484
+
485
+ it("the same warning/success ink clears the floor on background and color1", () => {
486
+ // Locks the surface-specificity so a future fix cannot be signed off by
487
+ // re-measuring against the white page.
488
+ const reports = assertContrast(
489
+ (["warning", "success"] as const).flatMap((intent) =>
490
+ (["background", "color1"] as const).map((surface) => ({
491
+ foreground: themes[`light_${intent}`].color11,
492
+ background: themes.light[surface],
493
+ label: `light_${intent}: color11 on light.${surface}`,
494
+ })),
495
+ ),
496
+ );
497
+ expect(reports.map((report) => report.ratio)).toEqual([4.57, 4.57, 4.72, 4.72]);
498
+ });
499
+ });
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createElement, type ReactNode } from "react";
@@ -26,11 +26,40 @@ import {
26
26
  readComputedCornerShape,
27
27
  } from "./cornerSmoothing";
28
28
 
29
+ /**
30
+ * Playwright's `chrome-headless-shell` builds, newest revision first.
31
+ *
32
+ * This is about coverage, not speed — an installed Chrome reaches `--dump-dom`
33
+ * in ~5s and is fine. The point is that a machine or a CI image with no Chrome
34
+ * installed but a Playwright cache present would otherwise take the held-out
35
+ * branch below and prove nothing, silently.
36
+ */
37
+ function findHeadlessShells(): string[] {
38
+ const home = process.env.HOME ?? "";
39
+ const roots = [`${home}/Library/Caches/ms-playwright`, `${home}/.cache/ms-playwright`];
40
+ const found: { revision: number; bin: string }[] = [];
41
+ for (const root of roots) {
42
+ if (!existsSync(root)) continue;
43
+ for (const entry of readdirSync(root)) {
44
+ const revision = Number(entry.replace("chromium_headless_shell-", ""));
45
+ if (!entry.startsWith("chromium_headless_shell-") || Number.isNaN(revision)) continue;
46
+ for (const arch of ["mac-arm64", "mac-x64", "linux64"]) {
47
+ const bin = join(root, entry, `chrome-headless-shell-${arch}`, "chrome-headless-shell");
48
+ if (existsSync(bin)) found.push({ revision, bin });
49
+ }
50
+ }
51
+ }
52
+ return found.sort((a, b) => b.revision - a.revision).map((f) => f.bin);
53
+ }
54
+
29
55
  function findChrome(): string | undefined {
30
56
  const candidates = [
31
57
  process.env.CHROME_BIN,
58
+ ...findHeadlessShells(),
32
59
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
33
60
  `${process.env.HOME ?? ""}/.local/bin/google-chrome`,
61
+ "/usr/bin/google-chrome",
62
+ "/usr/bin/chromium",
34
63
  ].filter((p): p is string => Boolean(p));
35
64
  return candidates.find((p) => existsSync(p));
36
65
  }
@@ -188,26 +217,42 @@ describe("cornerSmoothing (Axiom 15 OPTICS)", () => {
188
217
  writeFileSync(
189
218
  htmlPath,
190
219
  `<!doctype html><style>
191
- .box { width: 132px; height: 84px; border-radius: 16px; }
192
- .round { corner-shape: round; }
193
- .smooth { corner-shape: squircle; }
220
+ html, body { margin: 0; padding: 0; }
221
+ .box { position: absolute; top: 0; width: 132px; height: 84px; border-radius: 16px; }
222
+ .round { left: 0; corner-shape: round; }
223
+ .smooth { left: 200px; corner-shape: squircle; }
224
+ .classy { left: 400px; }
194
225
  .${cornerSmoothClassName} { corner-shape: squircle; }
195
226
  </style>
196
227
  <div id="round" class="box round"></div>
197
228
  <div id="smooth" class="box smooth"></div>
198
- <div id="classy" class="box ${cornerSmoothClassName}"></div>
229
+ <div id="classy" class="box classy ${cornerSmoothClassName}"></div>
199
230
  <pre id="out"></pre>
200
231
  <script>
201
232
  const read = (el) => {
202
233
  const cs = getComputedStyle(el);
203
234
  return (cs.getPropertyValue("corner-shape") || cs.cornerShape || "").trim();
204
235
  };
236
+ // Walk 1px..8px diagonally in from the top-left. "1" means the box owns that
237
+ // pixel. This reads the PAINTED curve, so it fails if an engine parses
238
+ // corner-shape but still draws the plain arc — a property echo cannot fake it.
239
+ const cornerMask = (id, left) => {
240
+ let bits = "";
241
+ for (let d = 1; d <= 8; d++) {
242
+ const hit = document.elementFromPoint(left + d, d);
243
+ bits += hit && hit.id === id ? "1" : "0";
244
+ }
245
+ return bits;
246
+ };
205
247
  const out = {
206
248
  supportsSquircle: CSS.supports("corner-shape", "squircle"),
207
249
  ua: navigator.userAgent,
208
250
  round: read(document.getElementById("round")),
209
251
  smooth: read(document.getElementById("smooth")),
210
252
  classy: read(document.getElementById("classy")),
253
+ roundCorner: cornerMask("round", 0),
254
+ smoothCorner: cornerMask("smooth", 200),
255
+ classyCorner: cornerMask("classy", 400),
211
256
  };
212
257
  document.getElementById("out").textContent = JSON.stringify(out);
213
258
  </script>`,
@@ -215,7 +260,7 @@ document.getElementById("out").textContent = JSON.stringify(out);
215
260
  const dump = execFileSync(
216
261
  chrome,
217
262
  ["--headless=new", "--disable-gpu", "--dump-dom", `file://${htmlPath}`],
218
- { encoding: "utf8", timeout: 25_000 },
263
+ { encoding: "utf8", timeout: 60_000 },
219
264
  );
220
265
  const match = dump.match(/<pre id="out">([\s\S]*?)<\/pre>/);
221
266
  expect(match, "probe <pre id=out> missing from dump-dom").toBeTruthy();
@@ -224,6 +269,9 @@ document.getElementById("out").textContent = JSON.stringify(out);
224
269
  round: string;
225
270
  smooth: string;
226
271
  classy: string;
272
+ roundCorner: string;
273
+ smoothCorner: string;
274
+ classyCorner: string;
227
275
  };
228
276
  expect(result.supportsSquircle).toBe(true);
229
277
  expect(isRoundComputedCornerShape(result.round)).toBe(true);
@@ -232,6 +280,15 @@ document.getElementById("out").textContent = JSON.stringify(out);
232
280
  expect(result.smooth).not.toBe(result.round);
233
281
  expect([CORNER_SHAPE_SMOOTH, CORNER_SHAPE_SMOOTH_COMPUTED]).toContain(result.smooth);
234
282
  expect([CORNER_SHAPE_ROUND, CORNER_SHAPE_ROUND_COMPUTED]).toContain(result.round);
283
+
284
+ // Geometry, not the property echo: the squircle claims corner pixels the
285
+ // arc leaves empty, and the class-driven cell paints the same curve as
286
+ // the hand-written one. Measured 2026-09-01 on Chromium 143/147/148/151
287
+ // and Chrome 152 — round "00011111", smooth "01111111". See
288
+ // docs/design/boards/W1-corner-smoothing/engines__corner-shape.json.
289
+ expect(result.smoothCorner).not.toBe(result.roundCorner);
290
+ expect(result.classyCorner).toBe(result.smoothCorner);
291
+ expect(result.smoothCorner.indexOf("1")).toBeLessThan(result.roundCorner.indexOf("1"));
235
292
  });
236
293
  });
237
294
  });
@@ -29,6 +29,21 @@
29
29
  * is false and computed `corner-shape` is empty — the silent fallback,
30
30
  * measured rather than assumed. Native has no `corner-shape`.
31
31
  *
32
+ * Re-measured 2026-09-01 across seven engines — the matrix and the probe that
33
+ * produced it live in `docs/design/boards/W1-corner-smoothing/`:
34
+ * - The property is live from at least Chromium 143, four minor versions
35
+ * below the 147 the INERT caption was shot on.
36
+ * - The computed serialization is NOT stable: it echoes the specified keyword
37
+ * (`round` / `squircle`) through 151 and normalizes to `superellipse(1)` /
38
+ * `superellipse(2)` from 152. Classify with the two helpers below; never
39
+ * compare a raw string.
40
+ * - The painted corner differs at both stops on every Chromium, read as
41
+ * geometry (`elementFromPoint` walking in from the corner), so the channel
42
+ * is proven to paint and not merely to parse.
43
+ * - WebKit 26 and Firefox 148: `CSS.supports` false, computed `corner-shape`
44
+ * empty, `border-radius` and the box footprint untouched, and the corner
45
+ * byte-identical between the stops. Silent degradation on a real engine.
46
+ *
32
47
  * Native twin: `cornerSmoothing.native.ts` (no CSS classes — native
33
48
  * resolves to `round` until a SquircleView adoption lands, phase 2).
34
49
  */
@@ -1,21 +1,24 @@
1
- import { createDefaultFont } from "../../font";
2
- import { fontCategoryStacks } from "../fontCategoryStacks";
1
+ import { defaultFonts } from "./fonts";
3
2
 
4
3
  /**
5
4
  * Category fonts merged into createDefaultThemeConfig UNDER consumer
6
5
  * heading/body overrides. FontKnobStyles looks these up as `$serif` etc.
7
6
  * so headingFont=serif writes `--f-family` even when the app only customizes
8
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.
9
12
  */
10
13
  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 }),
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,
21
24
  };
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { createDefaultFont } from "../../font";
3
+ import { flooredLineHeight, fontCategoryMetrics } from "../fontCategoryMetrics";
4
+ import { fontCategoryStacks } from "../fontCategoryStacks";
5
+ import type { FontCategory } from "../knobs";
3
6
  import {
4
- BODY_LINE_HEIGHT_OFFSET,
5
- bodyLineHeightPx,
6
7
  defaultBodyFont,
7
8
  defaultFonts,
8
9
  defaultHeadingFont,
@@ -23,6 +24,17 @@ function table(font: unknown, key: "size" | "lineHeight" | "letterSpacing"): Num
23
24
  return out;
24
25
  }
25
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
+
26
38
  describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-11)", () => {
27
39
  for (const [name, font] of [
28
40
  ["heading", defaultHeadingFont],
@@ -97,69 +109,127 @@ describe("default heading/body fonts — tracking-by-size (Axiom 15 OPTICS, LC-1
97
109
  });
98
110
  });
99
111
 
100
- describe("bodyFont category fonts — distinct families on the size+10 ramp (MPO-44)", () => {
112
+ describe("bodyFont category fonts — one ladder, distinct families (MPO-44)", () => {
101
113
  const bodyStops = ["body", "serif", "mono", "rounded"] as const;
102
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");
103
120
 
104
121
  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) {
122
+ expect(categories.length).toBe(10);
123
+ for (const key of categories) {
117
124
  expect(defaultFonts[key], key).toBeTruthy();
118
- expect(fontFamily(defaultFonts[key])).toBe(fontFamilyStacks[key]);
125
+ expect(fontFamily(defaultFonts[key])).toBe(fontCategoryStacks[key]);
119
126
  }
120
127
  expect(fontFamily(defaultFonts.body)).toContain("Inter");
121
128
  });
122
129
 
130
+ it("aliases fontFamilyStacks to the one fontCategoryStacks table", () => {
131
+ expect(fontFamilyStacks).toBe(fontCategoryStacks);
132
+ });
133
+
123
134
  it("gives the four bodyFont stops four distinct first families", () => {
124
135
  const firsts = bodyStops.map((key) => firstFamily(fontFamily(defaultFonts[key])));
125
136
  expect(firsts).toEqual(["Inter", "Georgia", "ui-monospace", "Nunito"]);
126
137
  expect(new Set(firsts).size).toBe(4);
127
138
  });
128
139
 
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);
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);
138
151
  }
139
152
  });
140
153
 
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");
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");
146
185
  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);
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);
149
208
  }
150
209
  }
151
210
  });
152
211
  });
153
212
 
154
- describe("createDefaultFont — body ramp (MPO-44)", () => {
155
- it("includes $true at 14/24 so app category fonts do not collapse to normal", () => {
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", () => {
156
215
  const font = createDefaultFont({ family: "Georgia, serif" });
157
216
  const size = table(font, "size");
158
217
  const lineHeight = table(font, "lineHeight");
159
- expect(size.true).toBe(14);
160
- expect(lineHeight.true).toBe(24);
218
+ expect(size.true).toBe(15);
219
+ expect(lineHeight.true).toBe(23);
161
220
  expect(fontFamily(font)).toBe("Georgia, serif");
162
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
+ });
163
233
  });
164
234
 
165
235
  function fontFamily(font: unknown): string {