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