@multiplatform.one/theme 7.5.0 → 7.6.1
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.
- package/package.json +5 -5
- package/src/audit/index.ts +19 -0
- package/src/audit/themeMatrix.spec.ts +274 -0
- package/src/audit/themeMatrix.ts +630 -0
- package/src/font.ts +36 -11
- package/src/index.ts +12 -0
- package/src/theme/FontKnobStyles.tsx +9 -28
- package/src/theme/colorRules.spec.ts +73 -0
- package/src/theme/cornerSmoothing.spec.ts +63 -6
- package/src/theme/cornerSmoothing.ts +15 -0
- package/src/theme/defaults/categoryFonts.ts +15 -12
- package/src/theme/defaults/fonts.spec.ts +106 -36
- package/src/theme/defaults/fonts.theme.spec.tsx +69 -58
- package/src/theme/defaults/fonts.ts +115 -79
- package/src/theme/focusState.spec.ts +17 -8
- package/src/theme/fontCategoryMetrics.ts +57 -0
- package/src/theme/fontCategoryStacks.ts +19 -6
- package/src/theme/index.ts +1 -0
- package/src/theme/resolveKnobs.spec.ts +16 -2
- package/src/theme/resolveKnobs.ts +3 -2
- package/types/audit/index.d.ts +2 -0
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +172 -0
- package/types/audit/themeMatrix.d.ts.map +1 -0
- package/types/font.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts +6 -11
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/cornerSmoothing.d.ts.map +1 -1
- package/types/theme/defaults/categoryFonts.d.ts +4 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +34 -29
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/fontCategoryMetrics.d.ts +36 -0
- package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
- package/types/theme/fontCategoryStacks.d.ts +18 -5
- package/types/theme/fontCategoryStacks.d.ts.map +1 -1
- package/types/theme/index.d.ts +1 -0
- package/types/theme/index.d.ts.map +1 -1
- 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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
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
|
|
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
|
|
170
|
-
if (
|
|
171
|
-
decls.push(`--f-lineHeight-${key}: ${
|
|
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
|
-
|
|
192
|
-
.
|
|
193
|
-
.
|
|
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:
|
|
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 {
|
|
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:
|
|
12
|
-
mono:
|
|
13
|
-
slab:
|
|
14
|
-
rounded:
|
|
15
|
-
condensed:
|
|
16
|
-
cursive:
|
|
17
|
-
handwriting:
|
|
18
|
-
pixel:
|
|
19
|
-
blackletter:
|
|
20
|
-
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
|
|
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
|
-
|
|
106
|
-
|
|
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(
|
|
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("
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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("
|
|
142
|
-
for (const [
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
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}
|
|
148
|
-
|
|
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
|
|
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(
|
|
160
|
-
expect(lineHeight.true).toBe(
|
|
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 {
|