@multiplatform.one/theme 7.5.0 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-matrix invariance + no-breakage assertions (MPO-18).
|
|
3
|
+
*
|
|
4
|
+
* The theme matrix sweeps every story cell across the globals axes
|
|
5
|
+
* (scheme × theme colour × preset) and holds TWO separate assertions,
|
|
6
|
+
* never one:
|
|
7
|
+
*
|
|
8
|
+
* 1. GEOMETRY INVARIANCE across the THEME axis — switching the theme
|
|
9
|
+
* colour may repaint ink, never move a box. Colour is the one axis
|
|
10
|
+
* whose whole contract is "paint only" (LC-80 RECIPE-INPUTS: size
|
|
11
|
+
* recipes are generated from inputs at config time; Tamagui colour
|
|
12
|
+
* themes stay the colour path). Any geometry delta across colours
|
|
13
|
+
* means a recipe consumed a theme option.
|
|
14
|
+
*
|
|
15
|
+
* 2. NO-BREAKAGE across the PRESET axis — presets are EXPECTED to move
|
|
16
|
+
* pixels (boldPreset flips radius/border/weight/space; heroPreset
|
|
17
|
+
* moves the page title scale), so geometry equality is deliberately
|
|
18
|
+
* NOT asserted there. What must hold in every preset cell: the story
|
|
19
|
+
* renders, structural values stay on the knob scales
|
|
20
|
+
* (runConstraintAudit off-scale count vs the default cell), and text
|
|
21
|
+
* ink keeps the WCAG floor (evaluateTextContrast).
|
|
22
|
+
*
|
|
23
|
+
* MEASURING RULE: every text assertion here measures TEXT NODES — the
|
|
24
|
+
* computed style of the element that DIRECTLY contains the text, and the
|
|
25
|
+
* Range-measured rect of the text itself — never the story frame. The
|
|
26
|
+
* frame's font-family is always Inter and its colour is not the ink
|
|
27
|
+
* (that trap shipped three wrong tickets: MPO-40/44/48).
|
|
28
|
+
*
|
|
29
|
+
* `captureMatrixSnapshot` is completely self-contained (no imports, no
|
|
30
|
+
* closures) so it can be passed straight to Playwright's
|
|
31
|
+
* `page.evaluate(captureMatrixSnapshot)`, exactly like
|
|
32
|
+
* `runConstraintAudit`. The comparators are pure Node-side functions so
|
|
33
|
+
* vitest can hold them to the tripwire tests without a browser.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { aaTextContrastRatio, measureContrast, minContrastRatio } from "../theme/colorRules";
|
|
37
|
+
|
|
38
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface MatrixRect {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
w: number;
|
|
44
|
+
h: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MatrixElementGeometry {
|
|
48
|
+
/** Stable structural path from the story root (tag:index-among-same-tag). */
|
|
49
|
+
key: string;
|
|
50
|
+
tag: string;
|
|
51
|
+
rect: MatrixRect;
|
|
52
|
+
/**
|
|
53
|
+
* Geometry-bearing computed properties. The theme axis must never move
|
|
54
|
+
* any of these; colour properties are deliberately absent.
|
|
55
|
+
*/
|
|
56
|
+
props: Record<string, string>;
|
|
57
|
+
/** Range-measured rect of the element's DIRECT text nodes, when any. */
|
|
58
|
+
textRect?: MatrixRect;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MatrixTextSample {
|
|
62
|
+
/** Structural key of the element that DIRECTLY contains the text node. */
|
|
63
|
+
key: string;
|
|
64
|
+
/** First characters of the text node (identification only). */
|
|
65
|
+
text: string;
|
|
66
|
+
/** Composited ink colour as #rrggbb (alpha already blended over background). */
|
|
67
|
+
color: string;
|
|
68
|
+
/** Composited effective background as #rrggbb. */
|
|
69
|
+
background: string;
|
|
70
|
+
/** False when no opaque backdrop could be resolved (gradient/image/alpha chain). */
|
|
71
|
+
backgroundResolved: boolean;
|
|
72
|
+
/** True when a background-image/gradient sits between text and backdrop. */
|
|
73
|
+
overImage: boolean;
|
|
74
|
+
fontSizePx: number;
|
|
75
|
+
fontWeightNum: number;
|
|
76
|
+
rect: MatrixRect;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MatrixSnapshot {
|
|
80
|
+
/** False when the story root is missing or empty — always a failure. */
|
|
81
|
+
storyRendered: boolean;
|
|
82
|
+
geometry: MatrixElementGeometry[];
|
|
83
|
+
textSamples: MatrixTextSample[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GeometryMove {
|
|
87
|
+
key: string;
|
|
88
|
+
property: string;
|
|
89
|
+
before: string;
|
|
90
|
+
after: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface GeometryDiff {
|
|
94
|
+
identical: boolean;
|
|
95
|
+
/** Elements present in `before` but missing in `after`. */
|
|
96
|
+
removedKeys: string[];
|
|
97
|
+
/** Elements present in `after` but missing in `before`. */
|
|
98
|
+
addedKeys: string[];
|
|
99
|
+
moves: GeometryMove[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TextContrastViolation {
|
|
103
|
+
key: string;
|
|
104
|
+
text: string;
|
|
105
|
+
color: string;
|
|
106
|
+
background: string;
|
|
107
|
+
ratio: number;
|
|
108
|
+
required: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TextContrastResult {
|
|
112
|
+
measured: number;
|
|
113
|
+
unmeasurable: number;
|
|
114
|
+
violations: TextContrastViolation[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Browser-side capture (self-contained for page.evaluate) ──────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Captures the geometry + text-ink snapshot of the rendered story.
|
|
121
|
+
*
|
|
122
|
+
* Self-contained by construction — pass directly to
|
|
123
|
+
* `page.evaluate(captureMatrixSnapshot)`.
|
|
124
|
+
*/
|
|
125
|
+
export function captureMatrixSnapshot(): MatrixSnapshot {
|
|
126
|
+
const SKIP_TAGS = new Set([
|
|
127
|
+
"script",
|
|
128
|
+
"style",
|
|
129
|
+
"link",
|
|
130
|
+
"meta",
|
|
131
|
+
"head",
|
|
132
|
+
"title",
|
|
133
|
+
"noscript",
|
|
134
|
+
"br",
|
|
135
|
+
"wbr",
|
|
136
|
+
"template",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const round = (n: number) => Math.round(n * 4) / 4;
|
|
140
|
+
|
|
141
|
+
const toRect = (r: DOMRect) => ({
|
|
142
|
+
x: round(r.x),
|
|
143
|
+
y: round(r.y),
|
|
144
|
+
w: round(r.width),
|
|
145
|
+
h: round(r.height),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// rgb()/rgba() → [r,g,b,a]; anything else → null (gradients, keywords).
|
|
149
|
+
function parseCssColor(raw: string): [number, number, number, number] | null {
|
|
150
|
+
const s = raw.trim().toLowerCase();
|
|
151
|
+
if (s === "transparent") return [0, 0, 0, 0];
|
|
152
|
+
const m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.%]+))?\s*\)$/);
|
|
153
|
+
if (!m) return null;
|
|
154
|
+
let a = 1;
|
|
155
|
+
if (m[4] !== undefined) {
|
|
156
|
+
a = m[4].endsWith("%") ? Number.parseFloat(m[4]) / 100 : Number.parseFloat(m[4]);
|
|
157
|
+
}
|
|
158
|
+
return [Number.parseFloat(m[1]), Number.parseFloat(m[2]), Number.parseFloat(m[3]), a];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toHex(rgb: [number, number, number]): string {
|
|
162
|
+
const h = (v: number) =>
|
|
163
|
+
Math.max(0, Math.min(255, Math.round(v)))
|
|
164
|
+
.toString(16)
|
|
165
|
+
.padStart(2, "0");
|
|
166
|
+
return `#${h(rgb[0])}${h(rgb[1])}${h(rgb[2])}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Composite `top` (with alpha) over opaque `bottom`.
|
|
170
|
+
function blend(
|
|
171
|
+
top: [number, number, number, number],
|
|
172
|
+
bottom: [number, number, number],
|
|
173
|
+
): [number, number, number] {
|
|
174
|
+
const a = top[3];
|
|
175
|
+
return [
|
|
176
|
+
top[0] * a + bottom[0] * (1 - a),
|
|
177
|
+
top[1] * a + bottom[1] * (1 - a),
|
|
178
|
+
top[2] * a + bottom[2] * (1 - a),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Effective backdrop behind an element: walk ancestors collecting
|
|
184
|
+
* backgroundColor layers until one is opaque, then composite downward.
|
|
185
|
+
* Reports whether the chain actually resolved and whether a
|
|
186
|
+
* background-image sits in it.
|
|
187
|
+
*/
|
|
188
|
+
function effectiveBackground(el: Element): {
|
|
189
|
+
hex: string;
|
|
190
|
+
resolved: boolean;
|
|
191
|
+
overImage: boolean;
|
|
192
|
+
} {
|
|
193
|
+
const layers: Array<[number, number, number, number]> = [];
|
|
194
|
+
let overImage = false;
|
|
195
|
+
let node: Element | null = el;
|
|
196
|
+
let opaqueBase: [number, number, number] | null = null;
|
|
197
|
+
while (node) {
|
|
198
|
+
const cs = getComputedStyle(node);
|
|
199
|
+
if (cs.backgroundImage && cs.backgroundImage !== "none") overImage = true;
|
|
200
|
+
const parsed = parseCssColor(cs.backgroundColor);
|
|
201
|
+
if (parsed && parsed[3] > 0) {
|
|
202
|
+
if (parsed[3] >= 0.999) {
|
|
203
|
+
opaqueBase = [parsed[0], parsed[1], parsed[2]];
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
layers.push(parsed);
|
|
207
|
+
}
|
|
208
|
+
node = node.parentElement;
|
|
209
|
+
}
|
|
210
|
+
if (!opaqueBase) {
|
|
211
|
+
// No opaque layer up the chain — an honest measurement is impossible.
|
|
212
|
+
return { hex: "#ffffff", resolved: false, overImage };
|
|
213
|
+
}
|
|
214
|
+
let acc = opaqueBase;
|
|
215
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
216
|
+
acc = blend(layers[i], acc);
|
|
217
|
+
}
|
|
218
|
+
return { hex: toHex(acc), resolved: !overImage, overImage };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Multiplied opacity from the element up to the root.
|
|
222
|
+
function effectiveOpacity(el: Element): number {
|
|
223
|
+
let o = 1;
|
|
224
|
+
let node: Element | null = el;
|
|
225
|
+
while (node) {
|
|
226
|
+
const v = Number.parseFloat(getComputedStyle(node).opacity);
|
|
227
|
+
if (!Number.isNaN(v)) o *= v;
|
|
228
|
+
node = node.parentElement;
|
|
229
|
+
}
|
|
230
|
+
return o;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const GEOMETRY_PROPS = [
|
|
234
|
+
"borderTopLeftRadius",
|
|
235
|
+
"borderTopRightRadius",
|
|
236
|
+
"borderBottomLeftRadius",
|
|
237
|
+
"borderBottomRightRadius",
|
|
238
|
+
"borderTopWidth",
|
|
239
|
+
"borderRightWidth",
|
|
240
|
+
"borderBottomWidth",
|
|
241
|
+
"borderLeftWidth",
|
|
242
|
+
"paddingTop",
|
|
243
|
+
"paddingRight",
|
|
244
|
+
"paddingBottom",
|
|
245
|
+
"paddingLeft",
|
|
246
|
+
"rowGap",
|
|
247
|
+
"columnGap",
|
|
248
|
+
"fontSize",
|
|
249
|
+
"fontWeight",
|
|
250
|
+
"fontFamily",
|
|
251
|
+
"lineHeight",
|
|
252
|
+
"letterSpacing",
|
|
253
|
+
] as const;
|
|
254
|
+
|
|
255
|
+
// Prefer the preview's stable inner root. `#storybook-root` also contains
|
|
256
|
+
// the Tamagui `<Theme>` host, which is present only when a colour global
|
|
257
|
+
// is set — capturing there reports a remount as geometry drift.
|
|
258
|
+
const root =
|
|
259
|
+
document.querySelector("#mpo-matrix-root") ??
|
|
260
|
+
document.querySelector("[data-mpo-matrix-root]") ??
|
|
261
|
+
document.querySelector("#storybook-root") ??
|
|
262
|
+
document.querySelector("#root") ??
|
|
263
|
+
document.body;
|
|
264
|
+
const storyRendered = !!root && root.children.length > 0;
|
|
265
|
+
|
|
266
|
+
const geometry: MatrixElementGeometry[] = [];
|
|
267
|
+
const textSamples: MatrixTextSample[] = [];
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The element's own painted text, if any: the Range-measured rect of its
|
|
271
|
+
* DIRECT text nodes plus the composited ink/backdrop sample. Returns the
|
|
272
|
+
* rect so a boxed element can carry it as `textRect`.
|
|
273
|
+
*
|
|
274
|
+
* Split out of `visit` so a boxless (`display: contents`) wrapper that
|
|
275
|
+
* happens to hold text still contributes its sample — dropping a text
|
|
276
|
+
* measurement would read as a pass.
|
|
277
|
+
*/
|
|
278
|
+
function collectText(el: Element, key: string, cs: CSSStyleDeclaration): MatrixRect | undefined {
|
|
279
|
+
const directText: Text[] = [];
|
|
280
|
+
for (const child of Array.from(el.childNodes)) {
|
|
281
|
+
if (child.nodeType === 3 && (child.textContent ?? "").trim().length > 0) {
|
|
282
|
+
directText.push(child as Text);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (directText.length === 0) return undefined;
|
|
286
|
+
const range = document.createRange();
|
|
287
|
+
range.setStartBefore(directText[0]);
|
|
288
|
+
range.setEndAfter(directText[directText.length - 1]);
|
|
289
|
+
const textRect = range.getBoundingClientRect();
|
|
290
|
+
if (!(textRect.width > 0 && textRect.height > 0)) return undefined;
|
|
291
|
+
const opacity = effectiveOpacity(el);
|
|
292
|
+
if (opacity > 0.05 && cs.visibility !== "hidden") {
|
|
293
|
+
const ink = parseCssColor(cs.color);
|
|
294
|
+
const backdrop = effectiveBackground(el);
|
|
295
|
+
if (ink) {
|
|
296
|
+
const baseParsed = parseCssColor(backdrop.hex) ?? [255, 255, 255, 1];
|
|
297
|
+
const inkOpaque = toHex(
|
|
298
|
+
ink[3] >= 0.999
|
|
299
|
+
? [ink[0], ink[1], ink[2]]
|
|
300
|
+
: blend(ink, [baseParsed[0], baseParsed[1], baseParsed[2]]),
|
|
301
|
+
);
|
|
302
|
+
textSamples.push({
|
|
303
|
+
key,
|
|
304
|
+
text: (directText[0].textContent ?? "").trim().slice(0, 60),
|
|
305
|
+
color: inkOpaque,
|
|
306
|
+
background: backdrop.hex,
|
|
307
|
+
backgroundResolved: backdrop.resolved,
|
|
308
|
+
overImage: backdrop.overImage,
|
|
309
|
+
fontSizePx: Number.parseFloat(cs.fontSize) || 0,
|
|
310
|
+
fontWeightNum: Number.parseFloat(cs.fontWeight) || 400,
|
|
311
|
+
rect: toRect(textRect),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return toRect(textRect);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The children that actually generate a BOX, hoisting straight through
|
|
320
|
+
* `display: contents`.
|
|
321
|
+
*
|
|
322
|
+
* This is what makes the structural key survive the colour axis. Tamagui's
|
|
323
|
+
* `<Theme>` emits a boxless passthrough span when the requested theme is
|
|
324
|
+
* already the active one and a real theme host when it differs, so the
|
|
325
|
+
* number of wrapper spans is a function of the colour global itself.
|
|
326
|
+
* Counting them made every coloured cell report `+N/-M elements` with ZERO
|
|
327
|
+
* moves — a keying artefact wearing the costume of a geometry failure.
|
|
328
|
+
* Measured on dogfood--settings at scheme:light: 158 nodes at the default
|
|
329
|
+
* colour, 151 with `color:blue`, 142 with `color:gray`; hoisting through
|
|
330
|
+
* the boxless wrappers gives 130 in every cell, light and dark, default and
|
|
331
|
+
* bold. Nothing an invariance gate can assert is lost, because an element
|
|
332
|
+
* with no box has no geometry to move.
|
|
333
|
+
*/
|
|
334
|
+
function boxedChildren(el: Element, path: string): Element[] {
|
|
335
|
+
const out: Element[] = [];
|
|
336
|
+
const counts: Record<string, number> = {};
|
|
337
|
+
for (const child of Array.from(el.children)) {
|
|
338
|
+
const tag = child.tagName.toLowerCase();
|
|
339
|
+
if (SKIP_TAGS.has(tag)) continue;
|
|
340
|
+
// SVG internals: the <svg> box is geometry enough; paths repaint per theme.
|
|
341
|
+
if (child.namespaceURI === "http://www.w3.org/2000/svg" && tag !== "svg") continue;
|
|
342
|
+
const cs = getComputedStyle(child);
|
|
343
|
+
if (cs.display === "none") continue;
|
|
344
|
+
if (cs.display === "contents") {
|
|
345
|
+
const index = counts[tag] ?? 0;
|
|
346
|
+
counts[tag] = index + 1;
|
|
347
|
+
collectText(child, `${path}/${tag}:${index}~contents`, cs);
|
|
348
|
+
for (const grandchild of boxedChildren(child, path)) out.push(grandchild);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
out.push(child);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function visit(el: Element, path: string): void {
|
|
357
|
+
const tag = el.tagName.toLowerCase();
|
|
358
|
+
const cs = getComputedStyle(el);
|
|
359
|
+
|
|
360
|
+
const rect = el.getBoundingClientRect();
|
|
361
|
+
const props: Record<string, string> = {};
|
|
362
|
+
for (const prop of GEOMETRY_PROPS) {
|
|
363
|
+
props[prop] = cs[prop as keyof CSSStyleDeclaration] as string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const entry: MatrixElementGeometry = {
|
|
367
|
+
key: path,
|
|
368
|
+
tag,
|
|
369
|
+
rect: toRect(rect),
|
|
370
|
+
props,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Direct text nodes — the painted text, measured via Range (never the frame).
|
|
374
|
+
const textRect = collectText(el, path, cs);
|
|
375
|
+
if (textRect) entry.textRect = textRect;
|
|
376
|
+
|
|
377
|
+
geometry.push(entry);
|
|
378
|
+
|
|
379
|
+
// Children keyed by tag-scoped sibling index so the path is stable when
|
|
380
|
+
// only class names change between theme cells.
|
|
381
|
+
const counts: Record<string, number> = {};
|
|
382
|
+
for (const child of boxedChildren(el, path)) {
|
|
383
|
+
const childTag = child.tagName.toLowerCase();
|
|
384
|
+
const index = counts[childTag] ?? 0;
|
|
385
|
+
counts[childTag] = index + 1;
|
|
386
|
+
visit(child, `${path}/${childTag}:${index}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (root) {
|
|
391
|
+
const counts: Record<string, number> = {};
|
|
392
|
+
for (const child of boxedChildren(root, "")) {
|
|
393
|
+
const childTag = child.tagName.toLowerCase();
|
|
394
|
+
const index = counts[childTag] ?? 0;
|
|
395
|
+
counts[childTag] = index + 1;
|
|
396
|
+
visit(child, `${childTag}:${index}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { storyRendered, geometry, textSamples };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Node-side comparators (pure, vitest-covered) ─────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Geometry invariance diff for the THEME axis: every element must keep its
|
|
407
|
+
* rect, its text rect, and every geometry-bearing computed property.
|
|
408
|
+
* `epsilonPx` absorbs sub-pixel raster jitter in rect measurements only —
|
|
409
|
+
* computed properties compare exactly.
|
|
410
|
+
*/
|
|
411
|
+
export function diffGeometry(
|
|
412
|
+
before: MatrixSnapshot,
|
|
413
|
+
after: MatrixSnapshot,
|
|
414
|
+
options: { epsilonPx?: number } = {},
|
|
415
|
+
): GeometryDiff {
|
|
416
|
+
const epsilon = options.epsilonPx ?? 0;
|
|
417
|
+
const byKey = new Map(before.geometry.map((g) => [g.key, g]));
|
|
418
|
+
const seen = new Set<string>();
|
|
419
|
+
const moves: GeometryMove[] = [];
|
|
420
|
+
const addedKeys: string[] = [];
|
|
421
|
+
|
|
422
|
+
const rectDiffers = (a: MatrixRect, b: MatrixRect) =>
|
|
423
|
+
Math.abs(a.x - b.x) > epsilon ||
|
|
424
|
+
Math.abs(a.y - b.y) > epsilon ||
|
|
425
|
+
Math.abs(a.w - b.w) > epsilon ||
|
|
426
|
+
Math.abs(a.h - b.h) > epsilon;
|
|
427
|
+
|
|
428
|
+
const formatRect = (r: MatrixRect) => `${r.x},${r.y} ${r.w}x${r.h}`;
|
|
429
|
+
|
|
430
|
+
for (const curr of after.geometry) {
|
|
431
|
+
const prev = byKey.get(curr.key);
|
|
432
|
+
if (!prev) {
|
|
433
|
+
addedKeys.push(curr.key);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
seen.add(curr.key);
|
|
437
|
+
if (rectDiffers(prev.rect, curr.rect)) {
|
|
438
|
+
moves.push({
|
|
439
|
+
key: curr.key,
|
|
440
|
+
property: "rect",
|
|
441
|
+
before: formatRect(prev.rect),
|
|
442
|
+
after: formatRect(curr.rect),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (prev.textRect && curr.textRect && rectDiffers(prev.textRect, curr.textRect)) {
|
|
446
|
+
moves.push({
|
|
447
|
+
key: curr.key,
|
|
448
|
+
property: "textRect",
|
|
449
|
+
before: formatRect(prev.textRect),
|
|
450
|
+
after: formatRect(curr.textRect),
|
|
451
|
+
});
|
|
452
|
+
} else if (!!prev.textRect !== !!curr.textRect) {
|
|
453
|
+
moves.push({
|
|
454
|
+
key: curr.key,
|
|
455
|
+
property: "textRect",
|
|
456
|
+
before: prev.textRect ? formatRect(prev.textRect) : "(none)",
|
|
457
|
+
after: curr.textRect ? formatRect(curr.textRect) : "(none)",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
for (const prop of Object.keys(prev.props)) {
|
|
461
|
+
if (prev.props[prop] !== curr.props[prop]) {
|
|
462
|
+
moves.push({
|
|
463
|
+
key: curr.key,
|
|
464
|
+
property: prop,
|
|
465
|
+
before: prev.props[prop],
|
|
466
|
+
after: curr.props[prop] ?? "(missing)",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const removedKeys = before.geometry.filter((g) => !seen.has(g.key)).map((g) => g.key);
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
identical: moves.length === 0 && addedKeys.length === 0 && removedKeys.length === 0,
|
|
476
|
+
removedKeys,
|
|
477
|
+
addedKeys,
|
|
478
|
+
moves,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The contrast assertion for the matrix (and for downstream repos — call
|
|
484
|
+
* this instead of keeping a local contrast spec): WCAG AA floors from
|
|
485
|
+
* `colorRules`, large text (≥24px, or ≥18.66px at ≥700) takes the 3:1
|
|
486
|
+
* floor, everything else 4.5:1. Samples whose backdrop could not be
|
|
487
|
+
* resolved (gradient/image/never-opaque chain) are counted, never
|
|
488
|
+
* silently dropped.
|
|
489
|
+
*/
|
|
490
|
+
export function evaluateTextContrast(samples: MatrixTextSample[]): TextContrastResult {
|
|
491
|
+
let measured = 0;
|
|
492
|
+
let unmeasurable = 0;
|
|
493
|
+
const violations: TextContrastViolation[] = [];
|
|
494
|
+
for (const sample of samples) {
|
|
495
|
+
if (!sample.backgroundResolved) {
|
|
496
|
+
unmeasurable++;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const large =
|
|
500
|
+
sample.fontSizePx >= 24 || (sample.fontSizePx >= 18.66 && sample.fontWeightNum >= 700);
|
|
501
|
+
const required = large ? minContrastRatio : aaTextContrastRatio;
|
|
502
|
+
// Same arithmetic as MPO-40's assertContrast — one floor, one report.
|
|
503
|
+
try {
|
|
504
|
+
const report = measureContrast({
|
|
505
|
+
foreground: sample.color,
|
|
506
|
+
background: sample.background,
|
|
507
|
+
floor: required,
|
|
508
|
+
label: sample.key,
|
|
509
|
+
});
|
|
510
|
+
measured++;
|
|
511
|
+
if (!report.pass) {
|
|
512
|
+
violations.push({
|
|
513
|
+
key: sample.key,
|
|
514
|
+
text: sample.text,
|
|
515
|
+
color: report.foreground,
|
|
516
|
+
background: report.background,
|
|
517
|
+
ratio: report.ratio,
|
|
518
|
+
required: report.floor,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
unmeasurable++;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return { measured, unmeasurable, violations };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** One matrix cell's no-breakage inputs (preset axis). */
|
|
529
|
+
export interface NoBreakageInput {
|
|
530
|
+
storyRendered: boolean;
|
|
531
|
+
/** Off-scale violation count from `runConstraintAudit` in this cell. */
|
|
532
|
+
offScaleViolations: number;
|
|
533
|
+
/** Off-scale count in the same story+scheme at the DEFAULT preset. */
|
|
534
|
+
baselineOffScaleViolations: number;
|
|
535
|
+
/**
|
|
536
|
+
* Per-violation signatures in this cell — see {@link auditSignatures}. A
|
|
537
|
+
* gate that reports "32 violations where the default cell had 31" names
|
|
538
|
+
* nothing a person can go and fix; with these it names the elements.
|
|
539
|
+
*/
|
|
540
|
+
offScaleSignatures?: string[];
|
|
541
|
+
/** The same signatures in the DEFAULT-preset cell. */
|
|
542
|
+
baselineOffScaleSignatures?: string[];
|
|
543
|
+
/** Contrast result of this cell. */
|
|
544
|
+
contrast: TextContrastResult;
|
|
545
|
+
/** Contrast violation count in the same story+scheme at the DEFAULT preset. */
|
|
546
|
+
baselineContrastViolations: number;
|
|
547
|
+
/** The DEFAULT-preset cell's contrast result, for naming the new misses. */
|
|
548
|
+
baselineContrast?: TextContrastResult;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Flattens a `runConstraintAudit` report into one signature per violation,
|
|
553
|
+
* `"<element label> :: <violation>"`, so two cells can be compared by what
|
|
554
|
+
* broke rather than by how many things broke.
|
|
555
|
+
*/
|
|
556
|
+
export function auditSignatures(
|
|
557
|
+
violations: Array<{ label: string; violations: string[] }>,
|
|
558
|
+
): string[] {
|
|
559
|
+
const out: string[] = [];
|
|
560
|
+
for (const element of violations) {
|
|
561
|
+
for (const violation of element.violations) out.push(`${element.label} :: ${violation}`);
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Multiset difference: entries in `after` beyond what `before` already had. */
|
|
567
|
+
function newEntries(before: string[], after: string[]): string[] {
|
|
568
|
+
const remaining = new Map<string, number>();
|
|
569
|
+
for (const entry of before) remaining.set(entry, (remaining.get(entry) ?? 0) + 1);
|
|
570
|
+
const added: string[] = [];
|
|
571
|
+
for (const entry of after) {
|
|
572
|
+
const count = remaining.get(entry) ?? 0;
|
|
573
|
+
if (count > 0) remaining.set(entry, count - 1);
|
|
574
|
+
else added.push(entry);
|
|
575
|
+
}
|
|
576
|
+
return added;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const namedList = (entries: string[], limit = 6): string => {
|
|
580
|
+
if (entries.length === 0) return "";
|
|
581
|
+
const shown = entries.slice(0, limit).join("; ");
|
|
582
|
+
return entries.length > limit
|
|
583
|
+
? ` — new: ${shown}; … ${entries.length - limit} more`
|
|
584
|
+
: ` — new: ${shown}`;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export interface NoBreakageResult {
|
|
588
|
+
ok: boolean;
|
|
589
|
+
failures: string[];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* NO-BREAKAGE evaluation for the preset axis. Geometry equality is
|
|
594
|
+
* deliberately NOT part of this: presets are expected to move pixels.
|
|
595
|
+
* Breakage means: the story failed to render, structural values fell off
|
|
596
|
+
* the knob scales beyond the default cell's standing debt, or text ink
|
|
597
|
+
* lost contrast the default cell still had.
|
|
598
|
+
*/
|
|
599
|
+
export function evaluateNoBreakage(input: NoBreakageInput): NoBreakageResult {
|
|
600
|
+
const failures: string[] = [];
|
|
601
|
+
if (!input.storyRendered) {
|
|
602
|
+
failures.push("story did not render (empty story root)");
|
|
603
|
+
}
|
|
604
|
+
if (input.offScaleViolations > input.baselineOffScaleViolations) {
|
|
605
|
+
const added =
|
|
606
|
+
input.offScaleSignatures && input.baselineOffScaleSignatures
|
|
607
|
+
? newEntries(input.baselineOffScaleSignatures, input.offScaleSignatures)
|
|
608
|
+
: [];
|
|
609
|
+
failures.push(
|
|
610
|
+
`constraint audit: ${input.offScaleViolations} off-scale violations ` +
|
|
611
|
+
`(default cell has ${input.baselineOffScaleViolations})${namedList(added)}`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (input.contrast.violations.length > input.baselineContrastViolations) {
|
|
615
|
+
const signature = (violation: TextContrastViolation) =>
|
|
616
|
+
`${violation.key} "${violation.text}" ${violation.color} on ${violation.background} = ` +
|
|
617
|
+
`${violation.ratio}:1 (floor ${violation.required})`;
|
|
618
|
+
const added = input.baselineContrast
|
|
619
|
+
? newEntries(
|
|
620
|
+
input.baselineContrast.violations.map(signature),
|
|
621
|
+
input.contrast.violations.map(signature),
|
|
622
|
+
)
|
|
623
|
+
: [];
|
|
624
|
+
failures.push(
|
|
625
|
+
`text contrast: ${input.contrast.violations.length} below-floor text nodes ` +
|
|
626
|
+
`(default cell has ${input.baselineContrastViolations})${namedList(added)}`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return { ok: failures.length === 0, failures };
|
|
630
|
+
}
|
package/src/font.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import type { GenericFont } from "@tamagui/web";
|
|
2
2
|
import { createFont } from "@tamagui/web";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
bodyFontLineHeights,
|
|
5
|
+
bodyFontSizes,
|
|
6
|
+
bodyLineHeightPx,
|
|
7
|
+
categoryLineHeights,
|
|
8
|
+
} from "./theme/defaults/fonts";
|
|
4
9
|
|
|
5
10
|
const defaults = {
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
11
|
+
// The era's `$body` ladder, including `true`. A missing `true` key is what
|
|
12
|
+
// collapsed `$serif`/`$mono`/`$rounded` to -apple-system 14px/normal on the
|
|
13
|
+
// label text node (MPO-44); a DIFFERENT ladder is the follow-on defect —
|
|
14
|
+
// a family knob that also resizes the label is still moving type off the
|
|
15
|
+
// ramp. A consumer font registered through this helper lands exactly where
|
|
16
|
+
// the built-in category fonts do.
|
|
9
17
|
size: bodyFontSizes,
|
|
18
|
+
// Floored at the generic body ratio (LC-11). `createDefaultFont` does not
|
|
19
|
+
// know which category it is building, so it takes the sans-serif floor;
|
|
20
|
+
// `defaults/fonts.ts` applies the per-category ones.
|
|
21
|
+
lineHeight: categoryLineHeights("sans-serif"),
|
|
10
22
|
// Neutral by default (LC-11): tracking is a per-family function of size,
|
|
11
23
|
// so a shared constant table is always wrong for someone — the previous
|
|
12
24
|
// Inter-ish table leaked +3px tracking into mono/script faces at $5/$6
|
|
13
|
-
// (see p-results/fixes-polish2-codeblock-density.md).
|
|
14
|
-
//
|
|
15
|
-
|
|
25
|
+
// (see p-results/fixes-polish2-codeblock-density.md). Explicit zeros, not
|
|
26
|
+
// an empty table: an empty table emits no `--f-letterSpacing-*` and the
|
|
27
|
+
// value falls through to the `:root` Inter curve.
|
|
28
|
+
letterSpacing: Object.fromEntries(Object.keys(bodyFontSizes).map((k) => [k, 0])) as Record<
|
|
29
|
+
string | number,
|
|
30
|
+
number
|
|
31
|
+
>,
|
|
32
|
+
// The era's body weight. Was 300, which is off the reference's 400 label
|
|
33
|
+
// weight in the light direction and made a bodyFont flip change weight.
|
|
16
34
|
weight: {
|
|
17
|
-
4: "
|
|
35
|
+
4: "400",
|
|
18
36
|
} as const,
|
|
19
37
|
};
|
|
20
38
|
|
|
@@ -22,10 +40,17 @@ export function createDefaultFont<A extends GenericFont>(
|
|
|
22
40
|
font: Partial<A> & { family: A["family"] },
|
|
23
41
|
): A {
|
|
24
42
|
const size = font.size || defaults.size;
|
|
43
|
+
const lineHeight =
|
|
44
|
+
size === defaults.size
|
|
45
|
+
? defaults.lineHeight
|
|
46
|
+
: (Object.fromEntries(
|
|
47
|
+
Object.entries(size).map(([k, v]) => [
|
|
48
|
+
k,
|
|
49
|
+
typeof v === "number" ? (bodyFontLineHeights[k] ?? bodyLineHeightPx(v)) : v,
|
|
50
|
+
]),
|
|
51
|
+
) as typeof size);
|
|
25
52
|
return createFont({
|
|
26
|
-
lineHeight
|
|
27
|
-
Object.entries(size).map(([k, v]) => [k, typeof v === "number" ? bodyLineHeightPx(v) : v]),
|
|
28
|
-
) as typeof size,
|
|
53
|
+
lineHeight,
|
|
29
54
|
letterSpacing: defaults.letterSpacing,
|
|
30
55
|
weight: defaults.weight,
|
|
31
56
|
...font,
|