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