@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
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @vitest-environment happy-dom
|
|
3
3
|
*
|
|
4
|
-
* Theme spec (MPO-44): bodyFont family
|
|
4
|
+
* Theme spec (MPO-44): bodyFont family, size and leading measured on the
|
|
5
|
+
* RENDERED TEXT NODE.
|
|
6
|
+
*
|
|
7
|
+
* Two traps this file exists to stay clear of.
|
|
8
|
+
*
|
|
5
9
|
* The button FRAME is always Inter — measuring it hid mono/serif/rounded
|
|
6
|
-
* collapsing to -apple-system 14px/normal.
|
|
7
|
-
*
|
|
10
|
+
* collapsing to -apple-system 14px/normal. So walk to the text node.
|
|
11
|
+
*
|
|
12
|
+
* And the measurement must come out of `getComputedStyle`, never out of the
|
|
13
|
+
* class name. An earlier version of this spec read `font_serif` off the
|
|
14
|
+
* element and then looked the family up in the source table, which asserts
|
|
15
|
+
* that the table is the table. It also wrote `expect(parseFloat(lh) || 24)`,
|
|
16
|
+
* which passes when the leading is unreadable, and guarded the size check
|
|
17
|
+
* behind `if (fontSize)`. Every assertion below reads a real computed value
|
|
18
|
+
* and fails when there is none.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
import { createElement } from "react";
|
|
@@ -17,10 +28,10 @@ import { TamaguiProvider, Text } from "tamagui";
|
|
|
17
28
|
import { createDefaultThemeConfig } from "../createDefaultThemeConfig";
|
|
18
29
|
import { defaultKnobs } from "../knobs";
|
|
19
30
|
import { resolveKnobs } from "../resolveKnobs";
|
|
20
|
-
import { fontFamilyStacks } from "./fonts";
|
|
21
31
|
|
|
22
32
|
const themeConfig = createDefaultThemeConfig();
|
|
23
33
|
|
|
34
|
+
/** The stops the button-knobs board shoots, in board order. */
|
|
24
35
|
const BODY_STOPS = [
|
|
25
36
|
{ stop: "sans-serif", token: "body", firstFamily: "Inter" },
|
|
26
37
|
{ stop: "serif", token: "serif", firstFamily: "Georgia" },
|
|
@@ -28,6 +39,10 @@ const BODY_STOPS = [
|
|
|
28
39
|
{ stop: "rounded", token: "rounded", firstFamily: "Nunito" },
|
|
29
40
|
] as const;
|
|
30
41
|
|
|
42
|
+
/** One ladder for every stop: `$true` is the era's body step (MPO-19 X8/X9). */
|
|
43
|
+
const LABEL_SIZE_PX = 15;
|
|
44
|
+
const LABEL_LEADING_PX = 23;
|
|
45
|
+
|
|
31
46
|
afterEach(cleanup);
|
|
32
47
|
|
|
33
48
|
/** Parent of the text node whose data is `label` — never a wrapping frame. */
|
|
@@ -50,78 +65,74 @@ function measureLabel(bodyFont: (typeof BODY_STOPS)[number]["stop"], scheme: "li
|
|
|
50
65
|
createElement(
|
|
51
66
|
TamaguiProvider,
|
|
52
67
|
{ config: themeConfig.tamagui, defaultTheme: scheme },
|
|
53
|
-
createElement(
|
|
54
|
-
Text,
|
|
55
|
-
{ ...knobs.knobProps.body, fontSize: "$true", "data-testid": "body-label" },
|
|
56
|
-
label,
|
|
57
|
-
),
|
|
68
|
+
createElement(Text, { ...knobs.knobProps.body, fontSize: "$true" }, label),
|
|
58
69
|
),
|
|
59
70
|
);
|
|
60
71
|
const host = textNodeHost(container, label);
|
|
61
72
|
const cs = getComputedStyle(host);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
return cs.fontFamily;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function specifiedLineHeight(el: HTMLElement): string {
|
|
83
|
-
const cs = getComputedStyle(el);
|
|
84
|
-
for (const prop of ["--f-lineHeight-true", "--f-lineHeight-4"]) {
|
|
85
|
-
const value = cs.getPropertyValue(prop).trim();
|
|
86
|
-
if (value) return value;
|
|
87
|
-
}
|
|
88
|
-
return cs.lineHeight;
|
|
73
|
+
const px = (value: string) => {
|
|
74
|
+
const n = Number.parseFloat(value);
|
|
75
|
+
if (!Number.isFinite(n)) throw new Error(`unreadable length ${JSON.stringify(value)}`);
|
|
76
|
+
return n;
|
|
77
|
+
};
|
|
78
|
+
const family = cs.fontFamily.trim();
|
|
79
|
+
if (!family) throw new Error("no computed font-family on the text node");
|
|
80
|
+
return {
|
|
81
|
+
host,
|
|
82
|
+
knobs,
|
|
83
|
+
family,
|
|
84
|
+
firstFamily: family.split(",")[0]?.replace(/['"]/g, "").trim() ?? "",
|
|
85
|
+
fontSize: px(cs.fontSize),
|
|
86
|
+
lineHeight: cs.lineHeight.trim(),
|
|
87
|
+
lineHeightPx: px(cs.lineHeight),
|
|
88
|
+
letterSpacing: cs.letterSpacing.trim(),
|
|
89
|
+
};
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
describe("bodyFont text-node family and leading (MPO-44)", () => {
|
|
92
|
+
describe("bodyFont text-node family, size and leading (MPO-44)", () => {
|
|
92
93
|
for (const scheme of ["light", "dark"] as const) {
|
|
93
94
|
describe(scheme, () => {
|
|
94
95
|
for (const { stop, token, firstFamily } of BODY_STOPS) {
|
|
95
|
-
it(`${stop}: text node is ${firstFamily}
|
|
96
|
-
const
|
|
97
|
-
expect(knobs.knobProps.body.fontFamily).toBe(`$${token
|
|
96
|
+
it(`${stop}: text node is ${firstFamily} ${LABEL_SIZE_PX}px/${LABEL_LEADING_PX}px`, () => {
|
|
97
|
+
const m = measureLabel(stop, scheme);
|
|
98
|
+
expect(m.knobs.knobProps.body.fontFamily).toBe(`$${token}`);
|
|
98
99
|
|
|
99
100
|
// Trap: a wrapping frame stays Inter. We measured the text node.
|
|
100
|
-
expect([...host.childNodes].some((n) => n.nodeType === Node.TEXT_NODE)).toBe(true);
|
|
101
|
+
expect([...m.host.childNodes].some((n) => n.nodeType === Node.TEXT_NODE)).toBe(true);
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
expect(m.firstFamily.toLowerCase()).toBe(firstFamily.toLowerCase());
|
|
104
|
+
// The Inter stack legitimately names -apple-system as a fallback;
|
|
105
|
+
// the defect was it arriving FIRST, which is what the text node reads.
|
|
106
|
+
expect(m.firstFamily.toLowerCase()).not.toBe("-apple-system");
|
|
104
107
|
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
-
expect(
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
expect(lineHeight.toLowerCase()).not.toBe("normal");
|
|
112
|
-
expect(parseFloat(lineHeight) || 24).toBe(24);
|
|
113
|
-
const fontSize = parseFloat(cs.fontSize);
|
|
114
|
-
if (fontSize) expect(fontSize).toBe(14);
|
|
108
|
+
expect(m.lineHeight.toLowerCase()).not.toBe("normal");
|
|
109
|
+
expect(m.lineHeightPx).toBe(LABEL_LEADING_PX);
|
|
110
|
+
expect(m.fontSize).toBe(LABEL_SIZE_PX);
|
|
111
|
+
expect(m.lineHeightPx / m.fontSize).toBeGreaterThanOrEqual(1.4);
|
|
115
112
|
});
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
it("the four stops resolve to four distinct family stacks", () => {
|
|
119
|
-
const families = BODY_STOPS.map((s) =>
|
|
120
|
-
const { host } = measureLabel(s.stop, scheme);
|
|
121
|
-
return specifiedFamily(host).split(",")[0]?.replace(/['"]/g, "").trim();
|
|
122
|
-
});
|
|
116
|
+
const families = BODY_STOPS.map((s) => measureLabel(s.stop, scheme).firstFamily);
|
|
123
117
|
expect(new Set(families).size).toBe(4);
|
|
124
|
-
expect(families.some((f) => f
|
|
118
|
+
expect(families.some((f) => f.toLowerCase() === "-apple-system")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("flipping the family knob moves the family and nothing else", () => {
|
|
122
|
+
const measured = BODY_STOPS.map((s) => measureLabel(s.stop, scheme));
|
|
123
|
+
const sizes = new Set(measured.map((m) => m.fontSize));
|
|
124
|
+
const leadings = new Set(measured.map((m) => m.lineHeightPx));
|
|
125
|
+
expect([...sizes]).toEqual([LABEL_SIZE_PX]);
|
|
126
|
+
expect([...leadings]).toEqual([LABEL_LEADING_PX]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not leak Inter's optical tracking onto the other faces (LC-11)", () => {
|
|
130
|
+
for (const { stop } of BODY_STOPS.filter((s) => s.stop !== "sans-serif")) {
|
|
131
|
+
const m = measureLabel(stop, scheme);
|
|
132
|
+
expect(Number.parseFloat(m.letterSpacing) || 0, `${stop} tracking`).toBe(0);
|
|
133
|
+
}
|
|
134
|
+
// Inter keeps its curve.
|
|
135
|
+
expect(Number.parseFloat(measureLabel("sans-serif", scheme).letterSpacing)).toBeLessThan(0);
|
|
125
136
|
});
|
|
126
137
|
});
|
|
127
138
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { defaultConfig } from "@tamagui/config/v5";
|
|
2
2
|
import { createInterFont } from "@tamagui/font-inter";
|
|
3
|
-
|
|
3
|
+
// `@tamagui/web`, not `tamagui`: createDefaultThemeConfig.spec.ts mocks the
|
|
4
|
+
// whole `tamagui` module down to `createTamagui`, and this module is on that
|
|
5
|
+
// spec's import graph.
|
|
6
|
+
import { createFont, type GenericFont } from "@tamagui/web";
|
|
7
|
+
import { flooredLineHeight, fontCategoryMetrics } from "../fontCategoryMetrics";
|
|
8
|
+
import { fontCategoryStacks } from "../fontCategoryStacks";
|
|
9
|
+
import type { FontCategory } from "../knobs";
|
|
4
10
|
|
|
5
11
|
// Default purpose fonts for components that reference $heading / $body
|
|
6
12
|
// directly, both backed by Inter.
|
|
@@ -27,14 +33,44 @@ import type { GenericFont } from "tamagui";
|
|
|
27
33
|
// increasingly tight at display sizes (the same optical behavior Apple's SF
|
|
28
34
|
// implements per point size). The curve is Inter's own dynamic-metrics
|
|
29
35
|
// model (rsms.me/inter/dynmetrics): tracking(em) = a + b·e^(c·size).
|
|
30
|
-
// These values are Inter-specific: when the headingFont/bodyFont knob picks
|
|
31
|
-
// another family, FontKnobStyles' per-category metrics (LC-11) neutralize
|
|
32
|
-
// this table for fixed-pitch and connected-script categories.
|
|
33
36
|
//
|
|
34
|
-
// Category fonts (
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
37
|
+
// ── Category fonts (MPO-44, re-derived against the era pin) ──────────────
|
|
38
|
+
//
|
|
39
|
+
// A bodyFont stop is a FAMILY choice. It must move the family and nothing
|
|
40
|
+
// else, so every category font is built from the era's own `$body` tables
|
|
41
|
+
// rather than a ramp of its own. Measured on the label text node before this
|
|
42
|
+
// change, both schemes:
|
|
43
|
+
//
|
|
44
|
+
// sans-serif Inter 15px/23px
|
|
45
|
+
// serif Georgia 14px/24px
|
|
46
|
+
// mono ui-monospace 14px/24px
|
|
47
|
+
// rounded Nunito 14px/24px
|
|
48
|
+
//
|
|
49
|
+
// Registering the families (the first half of MPO-44) stopped the collapse to
|
|
50
|
+
// -apple-system 14px/normal, but the era pin landed after it and moved `$body`
|
|
51
|
+
// to 15/23 — so ten of eleven stops were left on a second ladder, and flipping
|
|
52
|
+
// the knob resized the label by a pixel and moved its leading. Same defect
|
|
53
|
+
// class as the one MPO-44 was filed for: the knob was still taking type off
|
|
54
|
+
// the ramp, just more quietly.
|
|
55
|
+
//
|
|
56
|
+
// Three deliberate departures from `$body`, all upward and all LC-11:
|
|
57
|
+
//
|
|
58
|
+
// 1. Leading is floored per category — `flooredLineHeight` raises the era's
|
|
59
|
+
// value where the taper falls under the category's ratio, never lowers
|
|
60
|
+
// it. Only display steps move; every text step is `$body`'s own value.
|
|
61
|
+
// 2. Tracking is 0, not Inter's curve. That curve is Inter-specific, and an
|
|
62
|
+
// empty table is not neutral: the class Tamagui emits for `$mono` does
|
|
63
|
+
// not declare `--f-letterSpacing-*`, so the value fell through to the
|
|
64
|
+
// `:root` Inter one and Georgia and Nunito rendered at Inter's −0.13px.
|
|
65
|
+
// There is no measured optical curve for these faces, so 0 is the honest
|
|
66
|
+
// value rather than a borrowed one.
|
|
67
|
+
// 3. Weight is the era's (400 at every step). The previous default was 300,
|
|
68
|
+
// which is off the reference's label weight in the other direction.
|
|
69
|
+
//
|
|
70
|
+
// `$body` itself is untouched: it IS upstream's table under the pin, and
|
|
71
|
+
// MPO-19 owns it. Its ratio dips below LC-11's 1.4 body floor from $11 up;
|
|
72
|
+
// FontKnobStyles floors that scope on web, and correcting the table would
|
|
73
|
+
// have to be argued on MPO-19, not smuggled in here.
|
|
38
74
|
|
|
39
75
|
const TRACKING_A = -0.0223;
|
|
40
76
|
const TRACKING_B = 0.185;
|
|
@@ -50,39 +86,16 @@ export function interTrackingPx(sizePx: number): number {
|
|
|
50
86
|
return Math.round(interTrackingEm(sizePx) * sizePx * 100) / 100;
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Legacy `size + 10` leading. Kept for `createDefaultFont` callers that pass
|
|
91
|
+
* their own size table and therefore have no era leading to floor.
|
|
92
|
+
*/
|
|
54
93
|
export const BODY_LINE_HEIGHT_OFFSET = 10;
|
|
55
94
|
|
|
56
95
|
export function bodyLineHeightPx(sizePx: number): number {
|
|
57
96
|
return sizePx + BODY_LINE_HEIGHT_OFFSET;
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
// Mirrors @tamagui/font-inter's default size table (pinned 2.0.0-rc.41) so
|
|
61
|
-
// tracking can be computed per size key. The fonts.spec.ts invariant test
|
|
62
|
-
// (letterSpacing[k] === interTrackingPx(size[k]) on the BUILT font) catches
|
|
63
|
-
// any upstream drift. `true` is the default size token — omit it and
|
|
64
|
-
// fontFamily="$serif" falls back to the system face with line-height:normal.
|
|
65
|
-
// Category fonts (not heading/body) stay on this transcribed ramp.
|
|
66
|
-
export const bodyFontSizes: Record<string, number> = {
|
|
67
|
-
1: 11,
|
|
68
|
-
2: 12,
|
|
69
|
-
3: 13,
|
|
70
|
-
4: 14,
|
|
71
|
-
true: 14,
|
|
72
|
-
5: 16,
|
|
73
|
-
6: 18,
|
|
74
|
-
7: 20,
|
|
75
|
-
8: 23,
|
|
76
|
-
9: 30,
|
|
77
|
-
10: 46,
|
|
78
|
-
11: 55,
|
|
79
|
-
12: 62,
|
|
80
|
-
13: 72,
|
|
81
|
-
14: 92,
|
|
82
|
-
15: 114,
|
|
83
|
-
16: 134,
|
|
84
|
-
};
|
|
85
|
-
|
|
86
99
|
/**
|
|
87
100
|
* A tamagui font table with Variables unwrapped and `$` prefixes stripped.
|
|
88
101
|
* Upstream hands these back either raw or wrapped depending on where in the
|
|
@@ -108,11 +121,29 @@ function trackingFor(sizes: Record<string, number>): Record<string, number> {
|
|
|
108
121
|
return Object.fromEntries(Object.entries(sizes).map(([key, px]) => [key, interTrackingPx(px)]));
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
/** Zero tracking for every key of a size table (LC-11, non-Inter faces). */
|
|
125
|
+
function neutralTracking(sizes: Record<string, number>): Record<string, number> {
|
|
126
|
+
return Object.fromEntries(Object.keys(sizes).map((key) => [key, 0]));
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
const v5Body = defaultConfig.fonts.body;
|
|
112
130
|
const v5Heading = defaultConfig.fonts.heading;
|
|
113
131
|
const bodySizes = numbers(v5Body.size);
|
|
132
|
+
const bodyLineHeights = numbers(v5Body.lineHeight);
|
|
133
|
+
const bodyWeights = plain<string>(v5Body.weight);
|
|
114
134
|
const headingSizes = numbers(v5Heading.size);
|
|
115
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The era's body size table. Exported under its historical name because
|
|
138
|
+
* `createDefaultFont` defaults to it, so a consumer-registered category font
|
|
139
|
+
* lands on the same ladder as the built-in ones instead of a private copy
|
|
140
|
+
* that drifts at the next pin (MPO-19 X8/X9).
|
|
141
|
+
*/
|
|
142
|
+
export const bodyFontSizes: Record<string, number> = bodySizes;
|
|
143
|
+
|
|
144
|
+
/** The era's body leading, before any per-category floor. */
|
|
145
|
+
export const bodyFontLineHeights: Record<string, number> = bodyLineHeights;
|
|
146
|
+
|
|
116
147
|
/**
|
|
117
148
|
* Inter faces the workspace ships. v5 heading weights are sparse — 600 up to
|
|
118
149
|
* $5, 700 at $6–$8, 800 from $9 — and A4's reading of that is the point:
|
|
@@ -127,46 +158,45 @@ const interFaces = {
|
|
|
127
158
|
} as const;
|
|
128
159
|
|
|
129
160
|
/**
|
|
130
|
-
* Concrete family stacks for each `headingFont`/`bodyFont` category.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* (serif/mono are explicit system aliases; the rest are shipped webfonts).
|
|
161
|
+
* Concrete family stacks for each `headingFont`/`bodyFont` category. Alias of
|
|
162
|
+
* the one table in `fontCategoryStacks.ts`, which `FontKnobStyles` also reads
|
|
163
|
+
* for its `--f-family` fallback — one stop cannot render two faces.
|
|
134
164
|
*/
|
|
135
|
-
export const fontFamilyStacks =
|
|
136
|
-
"sans-serif":
|
|
137
|
-
'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
138
|
-
serif: "Georgia, 'Times New Roman', Times, serif",
|
|
139
|
-
mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
140
|
-
slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
|
|
141
|
-
rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
|
|
142
|
-
condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
|
|
143
|
-
cursive: "'Dancing Script', 'Pacifico', cursive",
|
|
144
|
-
handwriting: "'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive",
|
|
145
|
-
pixel: "'Silkscreen', 'Press Start 2P', monospace",
|
|
146
|
-
blackletter: "'UnifrakturCook', 'Fraktur', serif",
|
|
147
|
-
geometric: "'Futura', 'Poppins', 'Century Gothic', sans-serif",
|
|
148
|
-
} as const;
|
|
165
|
+
export const fontFamilyStacks = fontCategoryStacks;
|
|
149
166
|
|
|
150
|
-
export type FontFamilyCategory =
|
|
167
|
+
export type FontFamilyCategory = FontCategory;
|
|
151
168
|
|
|
152
169
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* floors when the knob restyles `$heading`/`$body`.
|
|
170
|
+
* Leading for one category: the era's body table with the category's LC-11
|
|
171
|
+
* floor applied. Every text step comes back as `$body`'s own value; only the
|
|
172
|
+
* display steps, where the era's taper eases below the floor, move up.
|
|
157
173
|
*/
|
|
158
|
-
export function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
sizeLineHeight: bodyLineHeightPx,
|
|
166
|
-
},
|
|
174
|
+
export function categoryLineHeights(category: FontCategory): Record<string, number> {
|
|
175
|
+
const minRatio = (fontCategoryMetrics[category] ?? fontCategoryMetrics["sans-serif"]).body;
|
|
176
|
+
return Object.fromEntries(
|
|
177
|
+
Object.entries(bodySizes).map(([key, px]) => [
|
|
178
|
+
key,
|
|
179
|
+
flooredLineHeight(px, bodyLineHeights[key] ?? bodyLineHeightPx(px), minRatio),
|
|
180
|
+
]),
|
|
167
181
|
);
|
|
168
182
|
}
|
|
169
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Category font on the `$body` ladder: same sizes (including `true`), same
|
|
186
|
+
* weights, leading floored per LC-11, tracking neutral. A missing `true` key
|
|
187
|
+
* is what collapsed `$serif`/`$mono`/`$rounded` to the system face at
|
|
188
|
+
* `line-height: normal` (MPO-44).
|
|
189
|
+
*/
|
|
190
|
+
export function createCategoryFont(category: FontCategory, family: string): GenericFont {
|
|
191
|
+
return createFont({
|
|
192
|
+
family,
|
|
193
|
+
size: bodySizes,
|
|
194
|
+
lineHeight: categoryLineHeights(category),
|
|
195
|
+
weight: bodyWeights,
|
|
196
|
+
letterSpacing: neutralTracking(bodySizes),
|
|
197
|
+
}) as GenericFont;
|
|
198
|
+
}
|
|
199
|
+
|
|
170
200
|
export const defaultHeadingFont = createInterFont(
|
|
171
201
|
{
|
|
172
202
|
face: interFaces,
|
|
@@ -182,23 +212,29 @@ export const defaultBodyFont = createInterFont(
|
|
|
182
212
|
{
|
|
183
213
|
face: interFaces,
|
|
184
214
|
size: bodySizes,
|
|
185
|
-
lineHeight:
|
|
186
|
-
weight:
|
|
215
|
+
lineHeight: bodyLineHeights,
|
|
216
|
+
weight: bodyWeights,
|
|
187
217
|
letterSpacing: trackingFor(bodySizes),
|
|
188
218
|
},
|
|
189
219
|
{ sizeSize: (size) => size },
|
|
190
220
|
);
|
|
191
221
|
|
|
192
|
-
export const defaultSerifFont = createCategoryFont(fontFamilyStacks.serif);
|
|
193
|
-
export const defaultMonoFont = createCategoryFont(fontFamilyStacks.mono);
|
|
194
|
-
export const defaultSlabFont = createCategoryFont(fontFamilyStacks.slab);
|
|
195
|
-
export const defaultRoundedFont = createCategoryFont(fontFamilyStacks.rounded);
|
|
196
|
-
export const defaultCondensedFont = createCategoryFont(fontFamilyStacks.condensed);
|
|
197
|
-
export const defaultCursiveFont = createCategoryFont(fontFamilyStacks.cursive);
|
|
198
|
-
export const defaultHandwritingFont = createCategoryFont(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
export const defaultSerifFont = createCategoryFont("serif", fontFamilyStacks.serif);
|
|
223
|
+
export const defaultMonoFont = createCategoryFont("mono", fontFamilyStacks.mono);
|
|
224
|
+
export const defaultSlabFont = createCategoryFont("slab", fontFamilyStacks.slab);
|
|
225
|
+
export const defaultRoundedFont = createCategoryFont("rounded", fontFamilyStacks.rounded);
|
|
226
|
+
export const defaultCondensedFont = createCategoryFont("condensed", fontFamilyStacks.condensed);
|
|
227
|
+
export const defaultCursiveFont = createCategoryFont("cursive", fontFamilyStacks.cursive);
|
|
228
|
+
export const defaultHandwritingFont = createCategoryFont(
|
|
229
|
+
"handwriting",
|
|
230
|
+
fontFamilyStacks.handwriting,
|
|
231
|
+
);
|
|
232
|
+
export const defaultPixelFont = createCategoryFont("pixel", fontFamilyStacks.pixel);
|
|
233
|
+
export const defaultBlackletterFont = createCategoryFont(
|
|
234
|
+
"blackletter",
|
|
235
|
+
fontFamilyStacks.blackletter,
|
|
236
|
+
);
|
|
237
|
+
export const defaultGeometricFont = createCategoryFont("geometric", fontFamilyStacks.geometric);
|
|
202
238
|
|
|
203
239
|
/** All purpose + category fonts registered by `createDefaultThemeConfig`. */
|
|
204
240
|
export const defaultFonts = {
|
|
@@ -136,11 +136,22 @@ describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
it("the retired flat inset failed the floor over the solid intent fills (why offset is 0)", () => {
|
|
139
|
-
// The
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
139
|
+
// The failures that forced the flip (MPO-41 / MPO-19 X7): a −2 band paints
|
|
140
|
+
// INSIDE the control, so it had to contrast against the solid intent fill.
|
|
141
|
+
//
|
|
142
|
+
// Provenance, because the two numbers are easy to conflate. The headline
|
|
143
|
+
// figures — light accent 1.27:1, light error 1.17:1, dark error 1.31:1 —
|
|
144
|
+
// are RENDERED measurements off the action-and-feedback board at DPR 2,
|
|
145
|
+
// ring node against the Button's painted background. This test computes a
|
|
146
|
+
// DIFFERENT pair: the base `outlineColor` against the intent sub-theme's
|
|
147
|
+
// `color9` ink step (1.67 / 1.55 / 1.44 in this build). Same verdict, same
|
|
148
|
+
// reason, different surfaces — the board's error fills (#ce2c31 / #ff9794)
|
|
149
|
+
// are not in these default sub-theme ramps at all, so the fill side is the
|
|
150
|
+
// board's to own and not this package's.
|
|
151
|
+
//
|
|
152
|
+
// What this assertion IS: no sub-theme ink step the inset band could land
|
|
153
|
+
// on clears the floor, which keeps the offset-0 geometry load-bearing. If
|
|
154
|
+
// the ramps ever lighten past 3:1 this test goes stale, not wrong.
|
|
144
155
|
for (const [scheme, intent] of [
|
|
145
156
|
["light", "accent"],
|
|
146
157
|
["light", "error"],
|
|
@@ -196,9 +207,7 @@ describe("LC-71: keyboard-only; measure the ring / text node, not the frame", ()
|
|
|
196
207
|
});
|
|
197
208
|
|
|
198
209
|
it("chip dismiss rings the glyph node, not the 44px hit-box frame", () => {
|
|
199
|
-
const frameRule = compositeFocusRingCss.match(
|
|
200
|
-
/\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/,
|
|
201
|
-
);
|
|
210
|
+
const frameRule = compositeFocusRingCss.match(/\.mp-chip-dismiss:focus-visible\s*\{([^}]+)\}/);
|
|
202
211
|
const ringRule = compositeFocusRingCss.match(
|
|
203
212
|
/\.mp-chip-dismiss:focus-visible \.mp-chip-dismiss-ring\s*\{([^}]+)\}/,
|
|
204
213
|
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { FontCategory } from "./knobs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-category legibility floors (LC-11 FONT-FAMILY-METRICS).
|
|
5
|
+
*
|
|
6
|
+
* `heading` / `body` are minimum lineHeight:fontSize ratios. `letterSpacing:
|
|
7
|
+
* "neutral"` marks the families where Inter's tracking-by-size curve is
|
|
8
|
+
* actively wrong — fixed-pitch faces must keep their pitch, and connected
|
|
9
|
+
* scripts break when tracked apart.
|
|
10
|
+
*
|
|
11
|
+
* Two consumers read this table and they must never drift apart:
|
|
12
|
+
*
|
|
13
|
+
* - `FontKnobStyles` emits `--f-lineHeight-N` overrides on the `.font_body`
|
|
14
|
+
* / `.font_heading` CSS scopes, which is how the knob restyles text that
|
|
15
|
+
* never asked for a category token (web only).
|
|
16
|
+
* - `defaults/fonts.ts` bakes the same floors into each registered category
|
|
17
|
+
* font's own lineHeight table, which is the path the knob actually takes:
|
|
18
|
+
* `bodyFont: "serif"` renders `.font_serif`, and the CSS scope rules above
|
|
19
|
+
* are keyed to `.font_body`, so they never reach it. Native has no CSS at
|
|
20
|
+
* all and depends on the table entirely.
|
|
21
|
+
*
|
|
22
|
+
* "sans-serif" is the default scope and is listed for completeness — the
|
|
23
|
+
* `$body` / `$heading` tables are upstream's under the v5 era pin (MPO-19
|
|
24
|
+
* X8/X9) and this file does not rewrite them.
|
|
25
|
+
*/
|
|
26
|
+
export const fontCategoryMetrics: Record<
|
|
27
|
+
FontCategory,
|
|
28
|
+
{ heading: number; body: number; letterSpacing: "inherit" | "neutral" }
|
|
29
|
+
> = {
|
|
30
|
+
"sans-serif": { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
|
|
31
|
+
serif: { heading: 1.2, body: 1.4, letterSpacing: "inherit" },
|
|
32
|
+
mono: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
|
|
33
|
+
// Heading floors below are sized to each configured face's em box
|
|
34
|
+
// (ascent+descent) so display headings never clip (LC-11): Roboto Slab
|
|
35
|
+
// ≈1.32, Nunito ≈1.36, Silkscreen ≈1.3, Poppins/Futura ≈1.4.
|
|
36
|
+
slab: { heading: 1.35, body: 1.4, letterSpacing: "inherit" },
|
|
37
|
+
rounded: { heading: 1.4, body: 1.4, letterSpacing: "inherit" },
|
|
38
|
+
condensed: { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
|
|
39
|
+
cursive: { heading: 1.3, body: 1.5, letterSpacing: "neutral" },
|
|
40
|
+
handwriting: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
|
|
41
|
+
pixel: { heading: 1.35, body: 1.4, letterSpacing: "neutral" },
|
|
42
|
+
blackletter: { heading: 1.3, body: 1.45, letterSpacing: "neutral" },
|
|
43
|
+
geometric: { heading: 1.45, body: 1.4, letterSpacing: "inherit" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Leading for one size step: the era's own value, raised to the category's
|
|
48
|
+
* floor when the era's taper falls under it. Never below the base, so a
|
|
49
|
+
* category can only ever ask for MORE room than `$body` takes, never less.
|
|
50
|
+
*/
|
|
51
|
+
export function flooredLineHeight(
|
|
52
|
+
sizePx: number,
|
|
53
|
+
baseLineHeightPx: number,
|
|
54
|
+
minRatio: number,
|
|
55
|
+
): number {
|
|
56
|
+
return Math.max(baseLineHeightPx, Math.round(sizePx * minRatio));
|
|
57
|
+
}
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import type { FontCategory } from "./knobs";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* FontKnobStyles `--f-family` fallback when Tamagui has no `$serif` (etc.)
|
|
6
|
-
* font registered, and as the default category fonts on
|
|
7
|
-
* createDefaultThemeConfig so consumers do not have to pass a font map.
|
|
4
|
+
* The one concrete family stack per headingFont/bodyFont category.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* Both paths read THIS table, so a stop cannot render one face through the
|
|
7
|
+
* registered font and another through the CSS fallback:
|
|
8
|
+
*
|
|
9
|
+
* - `defaults/fonts.ts` builds the registered `$serif` / `$mono` / … fonts
|
|
10
|
+
* from it (the path the knob takes on every platform).
|
|
11
|
+
* - `FontKnobStyles` falls back to it for `--f-family` when a consumer's
|
|
12
|
+
* config has no font registered for the category (live SHC console had
|
|
13
|
+
* the style tag and skipped the family).
|
|
14
|
+
*
|
|
15
|
+
* Distinct FIRST families throughout, because a text-node measurement reads
|
|
16
|
+
* the first resolvable name — three stops that all begin at a system
|
|
17
|
+
* fallback is the MPO-44 defect.
|
|
18
|
+
*
|
|
19
|
+
* Tracks `packages/config/fonts.ts` (serif/mono/geometric are system aliases,
|
|
20
|
+
* the rest are shipped webfonts). `mono` is the one deliberate widening: the
|
|
21
|
+
* template's bare `monospace` keyword leaves the face to the UA, so this
|
|
22
|
+
* names the platform faces ahead of it and keeps the keyword last.
|
|
10
23
|
*/
|
|
11
24
|
export const fontCategoryStacks: Record<FontCategory, string> = {
|
|
12
25
|
"sans-serif":
|
|
13
26
|
'Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
14
27
|
serif: "Georgia, 'Times New Roman', Times, serif",
|
|
15
|
-
mono: "monospace",
|
|
28
|
+
mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
16
29
|
slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
|
|
17
30
|
rounded: "'Nunito', 'Varela Round', system-ui, sans-serif",
|
|
18
31
|
condensed: "'Roboto Condensed', 'Arial Narrow', sans-serif",
|
package/src/theme/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./defaults/index";
|
|
|
9
9
|
export * from "./devtools/ThemeDevtoolsPanel";
|
|
10
10
|
export * from "./focusState";
|
|
11
11
|
export * from "./glyphPaint";
|
|
12
|
+
export * from "./fontCategoryMetrics";
|
|
12
13
|
export * from "./fontCategoryStacks";
|
|
13
14
|
export * from "./FontKnobStyles";
|
|
14
15
|
export * from "./groupGeometry";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tokens } from "@tamagui/themes";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
defaultKnobs,
|
|
@@ -580,8 +581,7 @@ describe("resolveKnobs", () => {
|
|
|
580
581
|
).toBe("$6");
|
|
581
582
|
});
|
|
582
583
|
|
|
583
|
-
it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)",
|
|
584
|
-
const { tokens } = await import("@tamagui/themes");
|
|
584
|
+
it("mirrored px values agree with the live @tamagui/themes tokens (drift guard)", () => {
|
|
585
585
|
const radiusTokenByKnob = {
|
|
586
586
|
none: "$0",
|
|
587
587
|
small: "$2",
|
|
@@ -1133,6 +1133,20 @@ describe("applyDensity", () => {
|
|
|
1133
1133
|
expect(compact.knobProps.control.paddingHorizontal).toBe(16);
|
|
1134
1134
|
});
|
|
1135
1135
|
|
|
1136
|
+
it("names which gap density moves: the LAYOUT gap, never the recipe gap (LC-79)", () => {
|
|
1137
|
+
// `control.gap` tracks the RADIUS knob, so it is invariant under density
|
|
1138
|
+
// by construction. The gap a reader sees tighten is `knobProps.gap`, the
|
|
1139
|
+
// space-ramp one, and it only moves once `applyDensity` has stepped
|
|
1140
|
+
// `space` — `resolveKnobs` alone is a pure token mapper.
|
|
1141
|
+
const comfortable = resolveKnobs(applyDensity(knobsWith({ space: "medium" })));
|
|
1142
|
+
const compact = resolveKnobs(applyDensity(knobsWith({ space: "medium", density: "compact" })));
|
|
1143
|
+
expect(compact.knobProps.control.gap).toBe(comfortable.knobProps.control.gap);
|
|
1144
|
+
expect(compact.knobProps.gap).not.toEqual(comfortable.knobProps.gap);
|
|
1145
|
+
expect(compact.knobProps.panelPadding).not.toEqual(comfortable.knobProps.panelPadding);
|
|
1146
|
+
expect(compact.knobProps.control.height).toBe(44);
|
|
1147
|
+
expect(comfortable.knobProps.control.height).toBe(44);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1136
1150
|
it("compact density produces the same sizeToken and a smaller gap than comfortable", () => {
|
|
1137
1151
|
const comfortable = resolveKnobs(applyDensity(knobsWith({ size: "medium", space: "medium" })));
|
|
1138
1152
|
const compact = resolveKnobs(
|
|
@@ -97,8 +97,9 @@ const borderWidthMap = {
|
|
|
97
97
|
// ── Corner smoothing (Axiom 15 OPTICS) ────────────────────────
|
|
98
98
|
// `smooth` upgrades circular-arc corners to continuous curvature —
|
|
99
99
|
// `corner-shape: squircle` (≡ superellipse(2), ≈ Apple / Figma 0.6) — as a
|
|
100
|
-
// web progressive enhancement: Chromium
|
|
101
|
-
//
|
|
100
|
+
// web progressive enhancement: Chromium paints it (measured from 143; WebKit
|
|
101
|
+
// 26 and Firefox 148 drop the unknown declaration and keep the plain
|
|
102
|
+
// border-radius arc — docs/design/boards/W1-corner-smoothing/).
|
|
102
103
|
// Delivery is a class + mounted stylesheet (cornerSmoothing.ts) because
|
|
103
104
|
// Tamagui strips unknown keys from the `style` prop (skipProps) but merges
|
|
104
105
|
// `className`. Native resolves to `round` for now (SquircleView adoption is
|
package/types/audit/index.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ export type { AuditReport, AuditSummary, ElementAudit, PropertyAudit, TextProper
|
|
|
2
2
|
export { runConstraintAudit } from "./constraintAudit";
|
|
3
3
|
export type { AuditDiff, ElementDiff, PropertyDiff } from "./diffReport";
|
|
4
4
|
export { diffAuditReports, formatAuditReport } from "./diffReport";
|
|
5
|
+
export type { GeometryDiff, GeometryMove, MatrixElementGeometry, MatrixRect, MatrixSnapshot, MatrixTextSample, NoBreakageInput, NoBreakageResult, TextContrastResult, TextContrastViolation, } from "./themeMatrix";
|
|
6
|
+
export { auditSignatures, captureMatrixSnapshot, diffGeometry, evaluateNoBreakage, evaluateTextContrast, } from "./themeMatrix";
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/audit/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/audit/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACnE,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,qBAAqB,EACrB,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,YAAY,EACZ,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,eAAe,CAAC"}
|