@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.
Files changed (42) hide show
  1. package/package.json +5 -5
  2. package/src/audit/index.ts +19 -0
  3. package/src/audit/themeMatrix.spec.ts +274 -0
  4. package/src/audit/themeMatrix.ts +630 -0
  5. package/src/font.ts +36 -11
  6. package/src/index.ts +12 -0
  7. package/src/theme/FontKnobStyles.tsx +9 -28
  8. package/src/theme/colorRules.spec.ts +73 -0
  9. package/src/theme/cornerSmoothing.spec.ts +63 -6
  10. package/src/theme/cornerSmoothing.ts +15 -0
  11. package/src/theme/defaults/categoryFonts.ts +15 -12
  12. package/src/theme/defaults/fonts.spec.ts +106 -36
  13. package/src/theme/defaults/fonts.theme.spec.tsx +69 -58
  14. package/src/theme/defaults/fonts.ts +115 -79
  15. package/src/theme/focusState.spec.ts +17 -8
  16. package/src/theme/fontCategoryMetrics.ts +57 -0
  17. package/src/theme/fontCategoryStacks.ts +19 -6
  18. package/src/theme/index.ts +1 -0
  19. package/src/theme/resolveKnobs.spec.ts +16 -2
  20. package/src/theme/resolveKnobs.ts +3 -2
  21. package/types/audit/index.d.ts +2 -0
  22. package/types/audit/index.d.ts.map +1 -1
  23. package/types/audit/themeMatrix.d.ts +172 -0
  24. package/types/audit/themeMatrix.d.ts.map +1 -0
  25. package/types/font.d.ts.map +1 -1
  26. package/types/index.d.ts +2 -0
  27. package/types/index.d.ts.map +1 -1
  28. package/types/theme/FontKnobStyles.d.ts +6 -11
  29. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  30. package/types/theme/colorRules.d.ts +1 -1
  31. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  32. package/types/theme/defaults/categoryFonts.d.ts +4 -0
  33. package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
  34. package/types/theme/defaults/fonts.d.ts +34 -29
  35. package/types/theme/defaults/fonts.d.ts.map +1 -1
  36. package/types/theme/fontCategoryMetrics.d.ts +36 -0
  37. package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
  38. package/types/theme/fontCategoryStacks.d.ts +18 -5
  39. package/types/theme/fontCategoryStacks.d.ts.map +1 -1
  40. package/types/theme/index.d.ts +1 -0
  41. package/types/theme/index.d.ts.map +1 -1
  42. 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 { bodyFontSizes, bodyLineHeightPx } from "./theme/defaults/fonts";
3
+ import {
4
+ bodyFontLineHeights,
5
+ bodyFontSizes,
6
+ bodyLineHeightPx,
7
+ categoryLineHeights,
8
+ } from "./theme/defaults/fonts";
4
9
 
5
10
  const defaults = {
6
- // Same size table as Inter body, including `true: 14`. A missing `true`
7
- // key is what collapsed `$serif`/`$mono`/`$rounded` to -apple-system
8
- // 14px/normal on the label text node (MPO-44).
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). Families that want
14
- // optical tracking pass their own `letterSpacing` table.
15
- letterSpacing: {} as Record<string | number, number>,
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: "300",
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: Object.fromEntries(
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,