@multiplatform.one/theme 7.7.6 → 7.10.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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/src/audit/constraintAudit.spec.ts +157 -0
  3. package/src/audit/constraintAudit.ts +154 -35
  4. package/src/audit/constraintScope.spec.ts +48 -0
  5. package/src/audit/index.ts +16 -0
  6. package/src/audit/themeMatrix.spec.ts +292 -1
  7. package/src/audit/themeMatrix.ts +1015 -22
  8. package/src/theme/Intent.stories.tsx +88 -0
  9. package/src/theme/Preset.stories.tsx +80 -0
  10. package/src/theme/Surface.spec.tsx +6 -0
  11. package/src/theme/Surface.stories.tsx +91 -0
  12. package/src/theme/ThemeProvider.stories.tsx +50 -0
  13. package/src/theme/Tint.stories.tsx +98 -0
  14. package/src/theme/chartPalette.spec.ts +128 -5
  15. package/src/theme/chartPalette.ts +105 -22
  16. package/src/theme/colorRules.spec.ts +92 -39
  17. package/src/theme/colorRules.ts +4 -6
  18. package/src/theme/createDefaultThemeConfig.ts +1 -1
  19. package/src/theme/createThemes.ts +58 -3
  20. package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
  21. package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
  22. package/src/theme/glyphPaint.spec.ts +14 -0
  23. package/src/theme/glyphPaint.ts +2 -1
  24. package/src/theme/intent.spec.tsx +1 -0
  25. package/src/theme/layoutTokensHooks.spec.tsx +1 -0
  26. package/src/theme/recipeInputs.ts +9 -9
  27. package/src/theme/resolveKnobs.spec.ts +2 -2
  28. package/src/theme/sizeLadder.spec.ts +19 -10
  29. package/src/theme/sizeRecipes.ts +2 -2
  30. package/src/theme/themeValue.spec.ts +2 -2
  31. package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
  32. package/types/audit/constraintAudit.d.ts +17 -1
  33. package/types/audit/constraintAudit.d.ts.map +1 -1
  34. package/types/audit/index.d.ts +2 -2
  35. package/types/audit/index.d.ts.map +1 -1
  36. package/types/audit/themeMatrix.d.ts +135 -2
  37. package/types/audit/themeMatrix.d.ts.map +1 -1
  38. package/types/theme/chartPalette.d.ts +6 -4
  39. package/types/theme/chartPalette.d.ts.map +1 -1
  40. package/types/theme/colorRules.d.ts +3 -3
  41. package/types/theme/colorRules.d.ts.map +1 -1
  42. package/types/theme/createThemes.d.ts +1 -1
  43. package/types/theme/createThemes.d.ts.map +1 -1
  44. package/types/theme/glyphPaint.d.ts.map +1 -1
  45. package/types/theme/recipeInputs.d.ts +8 -8
  46. package/types/theme/recipeInputs.d.ts.map +1 -1
  47. package/types/theme/sizeRecipes.d.ts +2 -2
@@ -18,13 +18,21 @@
18
18
  * NOT asserted there. What must hold in every preset cell: the story
19
19
  * renders, structural values stay on the knob scales
20
20
  * (runConstraintAudit off-scale count vs the default cell), and text
21
- * ink keeps the WCAG floor (evaluateTextContrast).
21
+ * ink keeps the WCAG floor (evaluateTextContrast). Conveying SVG
22
+ * glyphs also receive an absolute 3:1 gate in every matrix cell.
22
23
  *
23
24
  * MEASURING RULE: every text assertion here measures TEXT NODES — the
24
25
  * computed style of the element that DIRECTLY contains the text, and the
25
26
  * Range-measured rect of the text itself — never the story frame. The
26
27
  * frame's font-family is always Inter and its colour is not the ink
27
28
  * (that trap shipped three wrong tickets: MPO-40/44/48).
29
+ * SVG measurements read descendant fill/stroke, including opacity. The
30
+ * bounded model handles single-ink shapes over a resolved solid backdrop;
31
+ * gradients, masks, filters, multicolor and unsupported group composition
32
+ * stay named and unverified. Explicitly declared charts route to a separate
33
+ * proof channel measuring ordered mark paint and SVG text. Geometry claims
34
+ * without a measured certificate stay unverified. Image-file SVGs, CSS glyphs and icon fonts
35
+ * are outside this channel's coverage.
28
36
  *
29
37
  * `captureMatrixSnapshot` is completely self-contained (no imports, no
30
38
  * closures) so it can be passed straight to Playwright's
@@ -76,11 +84,136 @@ export interface MatrixTextSample {
76
84
  rect: MatrixRect;
77
85
  }
78
86
 
87
+ export type IconExclusion = "disabled-control" | "decorative-standalone" | "hidden";
88
+ export type IconUnmeasurableReason =
89
+ | "unresolved-backdrop"
90
+ | "masked-or-filtered"
91
+ | "use-indirection"
92
+ | "unsupported-paint"
93
+ | "unsupported-compositing"
94
+ | "multicolor"
95
+ | "no-paint";
96
+
97
+ export interface MatrixIconSample {
98
+ key: string;
99
+ /** Name of the control owning this glyph, or the standalone SVG's name. */
100
+ context: string;
101
+ status: "conveying" | IconExclusion;
102
+ background: string;
103
+ backgroundResolved: boolean;
104
+ overImage: boolean;
105
+ /** Actual descendant ink, alpha-composited over the effective backdrop. */
106
+ paint?: string;
107
+ paintKinds?: Array<"fill" | "stroke">;
108
+ shapeCount: number;
109
+ distinctPaints: number;
110
+ unmeasurable?: IconUnmeasurableReason;
111
+ rect: MatrixRect;
112
+ }
113
+
114
+ export interface IconContrastViolation {
115
+ key: string;
116
+ context: string;
117
+ paint: string;
118
+ background: string;
119
+ ratio: number;
120
+ required: number;
121
+ }
122
+
123
+ export interface IconUnverified {
124
+ key: string;
125
+ context: string;
126
+ reason: IconUnmeasurableReason;
127
+ }
128
+
129
+ export interface IconContrastResult {
130
+ /** Absolute verdict. Unverified conveying paint cannot produce a pass. */
131
+ ok: boolean;
132
+ candidates: number;
133
+ measured: number;
134
+ violations: IconContrastViolation[];
135
+ unverified: IconUnverified[];
136
+ excludedDisabled: number;
137
+ excludedDecorative: number;
138
+ excludedHidden: number;
139
+ }
140
+
141
+ export type ChartKind = "bar" | "pie" | "donut" | "line" | "area";
142
+ export type ChartUnmeasurableReason =
143
+ | IconUnmeasurableReason
144
+ | "unknown-kind"
145
+ | "no-marks"
146
+ | "hidden-chart"
147
+ | "unverified-separation"
148
+ | "incomplete-datum-text"
149
+ | "unsupported-overlap";
150
+
151
+ export interface MatrixChartPaint {
152
+ key: string;
153
+ label: string;
154
+ background: string;
155
+ paint?: string;
156
+ unmeasurable?: ChartUnmeasurableReason;
157
+ }
158
+
159
+ export interface MatrixChartSample {
160
+ key: string;
161
+ kind: string;
162
+ context: string;
163
+ label: string;
164
+ background: string;
165
+ backgroundResolved: boolean;
166
+ marks: MatrixChartPaint[];
167
+ labels: MatrixChartPaint[];
168
+ /** Explicit thin axis/grid lines. Never treated as data-mark ink. */
169
+ chrome?: MatrixChartPaint[];
170
+ /** Painted drawables outside the declared mark/text/chrome channels. */
171
+ unaccountedPaint?: MatrixChartPaint[];
172
+ /** A component declaration is recorded, never accepted as geometry proof. */
173
+ separation: { declared: boolean; method?: "rect-bounds" | "eroded-arc"; minimumGapPx?: number };
174
+ datumText: {
175
+ declared: boolean;
176
+ expected: number;
177
+ observed: number;
178
+ complete: boolean;
179
+ viewId?: string;
180
+ expectedValues?: string;
181
+ verification?: "opened-dialog";
182
+ failure?: string;
183
+ };
184
+ unmeasurable?: ChartUnmeasurableReason;
185
+ }
186
+
187
+ export interface ChartProofViolation {
188
+ key: string;
189
+ context: string;
190
+ rule: "mark-backdrop" | "adjacent-pair" | "axis-text" | "missing-label";
191
+ paint?: string;
192
+ background?: string;
193
+ ratio?: number;
194
+ required?: number;
195
+ }
196
+
197
+ export interface ChartProofResult {
198
+ ok: boolean;
199
+ candidates: number;
200
+ measured: number;
201
+ marksMeasured: number;
202
+ labelsMeasured: number;
203
+ byKind: Record<string, number>;
204
+ violations: ChartProofViolation[];
205
+ unverified: Array<{ key: string; context: string; reason: ChartUnmeasurableReason }>;
206
+ }
207
+
79
208
  export interface MatrixSnapshot {
80
209
  /** False when the story root is missing or empty — always a failure. */
81
210
  storyRendered: boolean;
82
211
  geometry: MatrixElementGeometry[];
83
212
  textSamples: MatrixTextSample[];
213
+ iconSamples: MatrixIconSample[];
214
+ chartSamples: MatrixChartSample[];
215
+ /** All top-level SVG roots inside the audited story, including hidden roots. */
216
+ svgRootCount: number;
84
217
  }
85
218
 
86
219
  export interface GeometryMove {
@@ -117,7 +250,7 @@ export interface TextContrastResult {
117
250
  // ── Browser-side capture (self-contained for page.evaluate) ──────────────────
118
251
 
119
252
  /**
120
- * Captures the geometry + text-ink snapshot of the rendered story.
253
+ * Captures geometry, direct text ink and SVG glyph paint in the rendered story.
121
254
  *
122
255
  * Self-contained by construction — pass directly to
123
256
  * `page.evaluate(captureMatrixSnapshot)`.
@@ -197,6 +330,7 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
197
330
  while (node) {
198
331
  const cs = getComputedStyle(node);
199
332
  if (cs.backgroundImage && cs.backgroundImage !== "none") overImage = true;
333
+ if (node.getAttribute("data-mpo-chart-backdrop") === "patterned") overImage = true;
200
334
  const parsed = parseCssColor(cs.backgroundColor);
201
335
  if (parsed && parsed[3] > 0) {
202
336
  if (parsed[3] >= 0.999) {
@@ -265,6 +399,601 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
265
399
 
266
400
  const geometry: MatrixElementGeometry[] = [];
267
401
  const textSamples: MatrixTextSample[] = [];
402
+ const iconSamples: MatrixIconSample[] = [];
403
+ const chartSamples: MatrixChartSample[] = [];
404
+ let svgRootCount = 0;
405
+ const capturedSvgRoots = new Set<Element>();
406
+
407
+ function accessibleName(el: Element): string {
408
+ const labelledBy = el.getAttribute("aria-labelledby");
409
+ if (labelledBy) {
410
+ const name = labelledBy
411
+ .split(/\s+/)
412
+ .map((id) => document.getElementById(id)?.textContent ?? "")
413
+ .join(" ")
414
+ .trim();
415
+ if (name) return name.slice(0, 100);
416
+ }
417
+ const labels = "labels" in el ? (el as HTMLInputElement).labels : null;
418
+ const labelText = labels
419
+ ? Array.from(labels)
420
+ .map((label) => label.textContent ?? "")
421
+ .join(" ")
422
+ : "";
423
+ return (
424
+ el.getAttribute("aria-label") ||
425
+ labelText ||
426
+ el.getAttribute("title") ||
427
+ el.textContent ||
428
+ ""
429
+ )
430
+ .trim()
431
+ .slice(0, 100);
432
+ }
433
+
434
+ function controlName(el: Element): string {
435
+ // Compound fields put their glyph beside the named input. aria-hidden on
436
+ // that glyph avoids a duplicate AT stop; it does not make its paint optional.
437
+ if (el.matches("[data-mp-input-box]")) {
438
+ const input = el.querySelector("input, textarea, select");
439
+ if (input) return accessibleName(input) || input.getAttribute("placeholder") || "input";
440
+ }
441
+ return accessibleName(el);
442
+ }
443
+
444
+ function unsupportedCompositing(el: Element): boolean {
445
+ let backgroundInGroup = false;
446
+ for (let node: Element | null = el; node; node = node.parentElement) {
447
+ const cs = getComputedStyle(node);
448
+ const background = parseCssColor(cs.backgroundColor);
449
+ if (background && background[3] > 0) backgroundInGroup = true;
450
+ // Alpha on a group containing a background changes both sides of the
451
+ // contrast pair. Multiplying only ink alpha would invent a measurement.
452
+ if (Number.parseFloat(cs.opacity) < 1 && backgroundInGroup) return true;
453
+ if (cs.mixBlendMode && cs.mixBlendMode !== "normal") return true;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ function collectIcon(el: Element, key: string, cs: CSSStyleDeclaration): void {
459
+ if (el.tagName.toLowerCase() !== "svg" || el.parentElement?.closest("svg")) return;
460
+ const backdrop = effectiveBackground(el);
461
+ const rect = el.getBoundingClientRect();
462
+ const sample: MatrixIconSample = {
463
+ key,
464
+ context: accessibleName(el),
465
+ status: "conveying",
466
+ background: backdrop.hex,
467
+ backgroundResolved: backdrop.resolved,
468
+ overImage: backdrop.overImage,
469
+ shapeCount: 0,
470
+ distinctPaints: 0,
471
+ rect: toRect(rect),
472
+ };
473
+ iconSamples.push(sample);
474
+ if (
475
+ cs.visibility === "hidden" ||
476
+ cs.visibility === "collapse" ||
477
+ effectiveOpacity(el) === 0 ||
478
+ rect.width === 0 ||
479
+ rect.height === 0
480
+ ) {
481
+ sample.status = "hidden";
482
+ return;
483
+ }
484
+ if (el.closest('[disabled], [aria-disabled="true"], [data-disabled="true"]')) {
485
+ sample.status = "disabled-control";
486
+ return;
487
+ }
488
+ const controls =
489
+ 'button, a[href], label, summary, [data-mp-input-box], [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="option"], [role="combobox"]';
490
+ let namedControl: Element | null = el.closest(controls);
491
+ while (namedControl && !controlName(namedControl))
492
+ namedControl = namedControl.parentElement?.closest(controls) ?? null;
493
+ if (namedControl) sample.context = controlName(namedControl);
494
+ else if (el.closest('[aria-hidden="true"], [role="presentation"], [role="none"]')) {
495
+ sample.status = "decorative-standalone";
496
+ return;
497
+ }
498
+ if (!backdrop.resolved) {
499
+ sample.unmeasurable = "unresolved-backdrop";
500
+ return;
501
+ }
502
+ if (el.querySelector("use")) {
503
+ sample.unmeasurable = "use-indirection";
504
+ return;
505
+ }
506
+ const paintNodes = [el, ...Array.from(el.querySelectorAll("*"))];
507
+ for (let ancestor = el.parentElement; ancestor; ancestor = ancestor.parentElement)
508
+ paintNodes.push(ancestor);
509
+ if (
510
+ paintNodes.some((node) => {
511
+ const style = getComputedStyle(node);
512
+ return (
513
+ (style.filter && style.filter !== "none") ||
514
+ (style.maskImage && style.maskImage !== "none")
515
+ );
516
+ })
517
+ ) {
518
+ sample.unmeasurable = "masked-or-filtered";
519
+ return;
520
+ }
521
+ const background: [number, number, number] = [
522
+ Number.parseInt(backdrop.hex.slice(1, 3), 16),
523
+ Number.parseInt(backdrop.hex.slice(3, 5), 16),
524
+ Number.parseInt(backdrop.hex.slice(5, 7), 16),
525
+ ];
526
+ const paints = new Set<string>();
527
+ const kinds = new Set<"fill" | "stroke">();
528
+ const alphaValue = (raw: string) =>
529
+ raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
530
+ for (const shape of Array.from(
531
+ el.querySelectorAll("path, rect, circle, ellipse, line, polyline, polygon, text, tspan"),
532
+ )) {
533
+ // Definitions do not paint until referenced; <use> is scoped out above.
534
+ if (shape.closest("defs, clipPath, mask, pattern, marker, symbol")) continue;
535
+ const style = getComputedStyle(shape);
536
+ let hiddenGroup = false;
537
+ for (let node: Element | null = shape; node && node !== el; node = node.parentElement) {
538
+ if (getComputedStyle(node).display === "none") hiddenGroup = true;
539
+ }
540
+ if (
541
+ hiddenGroup ||
542
+ style.display === "none" ||
543
+ style.visibility === "hidden" ||
544
+ style.visibility === "collapse" ||
545
+ effectiveOpacity(shape) === 0
546
+ )
547
+ continue;
548
+ const bounds = (shape as SVGGraphicsElement).getBBox();
549
+ if (
550
+ typeof SVGGeometryElement !== "undefined" &&
551
+ shape instanceof SVGGeometryElement &&
552
+ shape.getTotalLength() === 0
553
+ )
554
+ continue;
555
+ sample.shapeCount++;
556
+ if (unsupportedCompositing(shape)) {
557
+ sample.unmeasurable = "unsupported-compositing";
558
+ return;
559
+ }
560
+ if (
561
+ [style.markerStart, style.markerMid, style.markerEnd].some(
562
+ (value) => value && value !== "none",
563
+ )
564
+ ) {
565
+ sample.unmeasurable = "unsupported-paint";
566
+ return;
567
+ }
568
+ for (const kind of ["fill", "stroke"] as const) {
569
+ if (
570
+ kind === "fill" &&
571
+ (shape.tagName.toLowerCase() === "line" || bounds.width === 0 || bounds.height === 0)
572
+ )
573
+ continue;
574
+ if (kind === "stroke" && !(Number.parseFloat(style.strokeWidth) > 0)) continue;
575
+ const raw = style[kind].trim().toLowerCase();
576
+ if (raw === "none") continue;
577
+ const ink = parseCssColor(raw === "currentcolor" ? style.color : raw);
578
+ if (!ink) {
579
+ sample.unmeasurable = "unsupported-paint";
580
+ return;
581
+ }
582
+ const paintOpacity = alphaValue(kind === "fill" ? style.fillOpacity : style.strokeOpacity);
583
+ ink[3] *= (Number.isFinite(paintOpacity) ? paintOpacity : 1) * effectiveOpacity(shape);
584
+ if (ink[3] === 0) continue;
585
+ paints.add(toHex(blend(ink, background)));
586
+ kinds.add(kind);
587
+ }
588
+ }
589
+ sample.distinctPaints = paints.size;
590
+ if (paints.size === 0) sample.unmeasurable = "no-paint";
591
+ else if (paints.size > 1) sample.unmeasurable = "multicolor";
592
+ else {
593
+ sample.paint = Array.from(paints)[0];
594
+ sample.paintKinds = Array.from(kinds);
595
+ }
596
+ }
597
+
598
+ function collectChart(el: Element, key: string): void {
599
+ const backdrop = effectiveBackground(el);
600
+ const rawCount = el.getAttribute("data-mpo-chart-datum-count");
601
+ const expected = rawCount !== null && /^\d+$/.test(rawCount) ? Number(rawCount) : -1;
602
+ const owner = el.closest("[data-mpo-chart-owner]");
603
+ const rows = owner
604
+ ? Array.from(owner.querySelectorAll("[data-mpo-chart-datum-text]")).filter(
605
+ (row) =>
606
+ row.tagName.toLowerCase() !== "svg" && row.closest("[data-mpo-chart-owner]") === owner,
607
+ )
608
+ : [];
609
+ const visible = (node: Element): boolean => {
610
+ const style = getComputedStyle(node);
611
+ const rect = node.getBoundingClientRect();
612
+ return (
613
+ style.visibility !== "hidden" &&
614
+ style.visibility !== "collapse" &&
615
+ effectiveOpacity(node) > 0 &&
616
+ (rect.width > 0 || rect.height > 0)
617
+ );
618
+ };
619
+ const completeRows = rows.filter((row, index) => {
620
+ const name = row.querySelector("[data-mpo-chart-datum-label]");
621
+ const value = row.querySelector("[data-mpo-chart-datum-value]");
622
+ return (
623
+ row.getAttribute("data-mpo-chart-datum-text") === String(index) &&
624
+ name &&
625
+ value &&
626
+ visible(name) &&
627
+ visible(value) &&
628
+ !!name.textContent?.trim() &&
629
+ !!value.textContent?.trim()
630
+ );
631
+ });
632
+ const sample: MatrixChartSample = {
633
+ key,
634
+ kind: el.getAttribute("data-mpo-chart") ?? "",
635
+ context: accessibleName(el),
636
+ label: (el.getAttribute("aria-label") ?? "").trim(),
637
+ background: backdrop.hex,
638
+ backgroundResolved: backdrop.resolved,
639
+ marks: [],
640
+ labels: [],
641
+ chrome: [],
642
+ unaccountedPaint: [],
643
+ separation: { declared: el.getAttribute("data-mpo-chart-separated") === "true" },
644
+ datumText: {
645
+ viewId: el.getAttribute("data-mpo-chart-data-view") ?? undefined,
646
+ expectedValues: el.getAttribute("data-mpo-chart-data-expected") ?? undefined,
647
+ declared: el.getAttribute("data-mpo-chart-datum-text") === "true",
648
+ expected,
649
+ observed: completeRows.length,
650
+ complete: expected > 0 && rows.length === expected && completeRows.length === expected,
651
+ },
652
+ };
653
+ chartSamples.push(sample);
654
+ if (!visible(el)) sample.unmeasurable = "hidden-chart";
655
+ else if (!backdrop.resolved) sample.unmeasurable = "unresolved-backdrop";
656
+ else if (el.querySelector("use")) sample.unmeasurable = "use-indirection";
657
+
658
+ const marks = Array.from(el.querySelectorAll("[data-mpo-chart-mark]"));
659
+ const certifiedMasks = new Set<Element>();
660
+ if ((sample.kind === "pie" || sample.kind === "donut") && marks.length > 1) {
661
+ // Certification checks the original partition as well as the mask. A
662
+ // matching mask on arbitrary overlapping paths would not prove a gap.
663
+ const round = (value: number) => Math.round(value * 1000) / 1000;
664
+ const pathTokens = (path: string) =>
665
+ JSON.stringify(
666
+ (
667
+ path
668
+ .replace(/^path\(["']?|["']?\)$/g, "")
669
+ .match(/[a-zA-Z]|[-+]?(?:\d*\.)?\d+(?:[eE][-+]?\d+)?/g) ?? []
670
+ ).map((token) => (Number.isNaN(Number(token)) ? token : Number(token))),
671
+ );
672
+ const point = (radius: number, angle: number) =>
673
+ `${round(radius * Math.cos(angle - Math.PI / 2))},${round(radius * Math.sin(angle - Math.PI / 2))}`;
674
+ const proofs = marks.map((mark) => {
675
+ if (
676
+ mark.tagName.toLowerCase() !== "path" ||
677
+ mark.getAttribute("data-mpo-chart-mask") !== "eroded-arc-v1"
678
+ )
679
+ return;
680
+ const values = (mark.getAttribute("data-mpo-chart-slice") ?? "").split(",").map(Number);
681
+ if (values.length !== 4 || !values.every(Number.isFinite)) return;
682
+ const [start, end, inner, outer] = values;
683
+ const span = end - start;
684
+ if (
685
+ start < 0 ||
686
+ end > Math.PI * 2 + 1e-9 ||
687
+ span <= 0 ||
688
+ span >= Math.PI * 2 ||
689
+ inner < 0 ||
690
+ outer <= inner
691
+ )
692
+ return;
693
+ const radius = (inner + outer) / 2;
694
+ const projection = Math.min(outer, Math.max(inner, radius * Math.cos(span / 2)));
695
+ const radialDistance = Math.sqrt(
696
+ Math.max(0, radius ** 2 + projection ** 2 - 2 * radius * projection * Math.cos(span / 2)),
697
+ );
698
+ if (outer - radius <= 1.002 || radius - inner <= 1.002 || radialDistance <= 1.002) return;
699
+ const large = Number(span >= Math.PI);
700
+ const d =
701
+ `M${point(outer, start)}A${round(outer)},${round(outer)},0,${large},1,${point(outer, end)}` +
702
+ (inner
703
+ ? `L${point(inner, end)}A${round(inner)},${round(inner)},0,${large},0,${point(inner, start)}Z`
704
+ : "L0,0Z");
705
+ if (mark.getAttribute("d") !== d) return;
706
+ const markStyle = getComputedStyle(mark);
707
+ // CSS can replace presentation attributes, including the actual path.
708
+ const paintedD = markStyle.getPropertyValue("d");
709
+ if (paintedD && pathTokens(paintedD) !== pathTokens(d)) return;
710
+ if (markStyle.stroke !== "none") return;
711
+ const reference = /^url\(["']?#([^"')]+)["']?\)$/.exec(mark.getAttribute("mask") ?? "");
712
+ if (!reference) return;
713
+ const matches = Array.from(document.querySelectorAll("[id]")).filter(
714
+ (node) => node.id === reference[1],
715
+ );
716
+ const mask = matches[0];
717
+ if (matches.length !== 1 || mask.tagName.toLowerCase() !== "mask" || !el.contains(mask))
718
+ return;
719
+ const definitions = mask.parentElement;
720
+ if (
721
+ definitions?.tagName.toLowerCase() !== "defs" ||
722
+ definitions.parentElement !== mark.parentElement
723
+ )
724
+ return;
725
+ const computedReference = /^url\(["']?([^"')]+)["']?\)$/.exec(markStyle.maskImage);
726
+ if (!computedReference || !["match-source", "luminance"].includes(markStyle.maskMode))
727
+ return;
728
+ try {
729
+ if (
730
+ new URL(computedReference[1], document.baseURI).href !==
731
+ new URL(`#${reference[1]}`, document.URL).href
732
+ )
733
+ return;
734
+ } catch {
735
+ return;
736
+ }
737
+ if (
738
+ mask.getAttribute("maskUnits") !== "userSpaceOnUse" ||
739
+ mask.getAttribute("maskContentUnits") !== "userSpaceOnUse" ||
740
+ getComputedStyle(mask).getPropertyValue("mask-type") !== "luminance"
741
+ )
742
+ return;
743
+ const bounds = {
744
+ x: -outer - 2,
745
+ y: -outer - 2,
746
+ width: (outer + 2) * 2,
747
+ height: (outer + 2) * 2,
748
+ };
749
+ if (
750
+ Object.entries(bounds).some(([name, value]) => Number(mask.getAttribute(name)) !== value)
751
+ )
752
+ return;
753
+ if (mask.children.length !== 1) return;
754
+ const boundary = mask.children[0];
755
+ if (boundary.tagName.toLowerCase() !== "path" || boundary.getAttribute("d") !== d) return;
756
+ for (const node of [definitions, mask, boundary]) {
757
+ const style = getComputedStyle(node);
758
+ if (
759
+ style.transform !== "none" ||
760
+ style.filter !== "none" ||
761
+ style.maskImage !== "none" ||
762
+ style.clipPath !== "none" ||
763
+ style.opacity !== "1" ||
764
+ style.visibility !== "visible" ||
765
+ style.display === "none"
766
+ )
767
+ return;
768
+ if (
769
+ [style.markerStart, style.markerMid, style.markerEnd].some(
770
+ (value) => value && value !== "none",
771
+ )
772
+ )
773
+ return;
774
+ }
775
+ if (unsupportedCompositing(boundary)) return;
776
+ const boundaryStyle = getComputedStyle(boundary);
777
+ if (
778
+ boundaryStyle.fill !== "rgb(255, 255, 255)" ||
779
+ boundaryStyle.stroke !== "rgb(0, 0, 0)" ||
780
+ boundaryStyle.fillOpacity !== "1" ||
781
+ boundaryStyle.strokeOpacity !== "1" ||
782
+ Number.parseFloat(boundaryStyle.strokeWidth) !== 2 ||
783
+ boundaryStyle.strokeLinejoin !== "round" ||
784
+ boundaryStyle.strokeLinecap !== "round" ||
785
+ boundaryStyle.paintOrder !== "normal" ||
786
+ boundaryStyle.vectorEffect !== "none" ||
787
+ boundaryStyle.strokeDasharray !== "none"
788
+ )
789
+ return;
790
+ const boundaryD = boundaryStyle.getPropertyValue("d");
791
+ if (boundaryD && pathTokens(boundaryD) !== pathTokens(d)) return;
792
+ for (let node: Element | null = mark; node; node = node.parentElement) {
793
+ const style = getComputedStyle(node);
794
+ if (
795
+ style.transform.startsWith("matrix3d") ||
796
+ (style.perspective && style.perspective !== "none")
797
+ )
798
+ return;
799
+ }
800
+ const ctm = (mark as SVGGraphicsElement).getScreenCTM();
801
+ if (!ctm) return;
802
+ const matrix = [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f];
803
+ // This two-hypot form avoids the squared discriminant's cancellation
804
+ // near rotations, where the singular values are equal.
805
+ const firstScale = Math.hypot(ctm.a + ctm.d, ctm.b - ctm.c);
806
+ const secondScale = Math.hypot(ctm.a - ctm.d, ctm.b + ctm.c);
807
+ const smallest = Math.abs(firstScale - secondScale) / 2;
808
+ if (!matrix.every(Number.isFinite) || !Number.isFinite(smallest) || smallest <= 0) return;
809
+ const measuredGap = 2 * smallest * window.devicePixelRatio;
810
+ // Unit rotations can round one double below2. Only the bounded
811
+ // arithmetic uncertainty at this boundary is snapped, never a visual
812
+ // tolerance. A representable shrink remains below the floor.
813
+ const gap = Math.abs(measuredGap - 2) <= 8 * Number.EPSILON ? 2 : measuredGap;
814
+ return {
815
+ mark,
816
+ start,
817
+ end,
818
+ inner,
819
+ outer,
820
+ matrix,
821
+ gap,
822
+ };
823
+ });
824
+ const first = proofs[0];
825
+ if (
826
+ first &&
827
+ Math.abs(first.start) < 1e-9 &&
828
+ proofs.every(
829
+ (proof, index) =>
830
+ proof &&
831
+ proof.inner === first.inner &&
832
+ proof.outer === first.outer &&
833
+ proof.matrix.every((value, axis) => value === first.matrix[axis]) &&
834
+ Math.abs(proof.start - (index ? proofs[index - 1]!.end : 0)) < 1e-9,
835
+ ) &&
836
+ Math.abs(proofs[proofs.length - 1]!.end - Math.PI * 2) < 1e-9
837
+ ) {
838
+ for (const proof of proofs) certifiedMasks.add(proof!.mark);
839
+ sample.separation.method = "eroded-arc";
840
+ sample.separation.minimumGapPx = first.gap;
841
+ }
842
+ }
843
+
844
+ function paintSample(shape: Element, paintKey: string, label: string): MatrixChartPaint {
845
+ const item: MatrixChartPaint = { key: paintKey, label, background: backdrop.hex };
846
+ if (shape.closest("defs, clipPath, mask, pattern, marker, symbol"))
847
+ return { ...item, unmeasurable: "no-paint" };
848
+ if (!visible(shape)) return { ...item, unmeasurable: "no-paint" };
849
+ if (!backdrop.resolved) return { ...item, unmeasurable: "unresolved-backdrop" };
850
+ for (let node: Element | null = shape; node; node = node.parentElement) {
851
+ const style = getComputedStyle(node);
852
+ if (
853
+ (style.filter && style.filter !== "none") ||
854
+ (style.maskImage &&
855
+ style.maskImage !== "none" &&
856
+ !(node === shape && certifiedMasks.has(shape)))
857
+ )
858
+ return { ...item, unmeasurable: "masked-or-filtered" };
859
+ // SVG clip paths and CSS clipping can hide portions of a mark. This
860
+ // bounded model does not certify the remaining painted shape.
861
+ if (style.clipPath && style.clipPath !== "none")
862
+ return { ...item, unmeasurable: "unsupported-paint" };
863
+ }
864
+ if (unsupportedCompositing(shape))
865
+ return { ...item, unmeasurable: "unsupported-compositing" };
866
+ const style = getComputedStyle(shape);
867
+ if (
868
+ [style.markerStart, style.markerMid, style.markerEnd].some(
869
+ (value) => value && value !== "none",
870
+ )
871
+ )
872
+ return { ...item, unmeasurable: "unsupported-paint" };
873
+ let bounds: DOMRect;
874
+ try {
875
+ bounds = (shape as SVGGraphicsElement).getBBox();
876
+ } catch {
877
+ return { ...item, unmeasurable: "unsupported-paint" };
878
+ }
879
+ const background: [number, number, number] = [
880
+ Number.parseInt(backdrop.hex.slice(1, 3), 16),
881
+ Number.parseInt(backdrop.hex.slice(3, 5), 16),
882
+ Number.parseInt(backdrop.hex.slice(5, 7), 16),
883
+ ];
884
+ const paints = new Set<string>();
885
+ for (const kind of ["fill", "stroke"] as const) {
886
+ if (
887
+ kind === "fill" &&
888
+ (shape.tagName.toLowerCase() === "line" || bounds.width === 0 || bounds.height === 0)
889
+ )
890
+ continue;
891
+ if (kind === "stroke" && !(Number.parseFloat(style.strokeWidth) > 0)) continue;
892
+ const raw = style[kind].trim().toLowerCase();
893
+ if (raw === "none") continue;
894
+ const ink = parseCssColor(raw === "currentcolor" ? style.color : raw);
895
+ if (!ink) return { ...item, unmeasurable: "unsupported-paint" };
896
+ const alphaRaw = kind === "fill" ? style.fillOpacity : style.strokeOpacity;
897
+ const alpha = Number.parseFloat(alphaRaw) / (alphaRaw.endsWith("%") ? 100 : 1);
898
+ ink[3] *= (Number.isFinite(alpha) ? alpha : 1) * effectiveOpacity(shape);
899
+ if (ink[3] > 0) paints.add(toHex(blend(ink, background)));
900
+ }
901
+ if (paints.size !== 1)
902
+ return { ...item, unmeasurable: paints.size ? "multicolor" : "no-paint" };
903
+ return { ...item, paint: Array.from(paints)[0] };
904
+ }
905
+
906
+ for (const [index, mark] of marks.entries()) {
907
+ const tag = mark.tagName.toLowerCase();
908
+ const item = !["path", "rect", "circle", "ellipse", "line", "polyline", "polygon"].includes(
909
+ tag,
910
+ )
911
+ ? {
912
+ key: `${key}/mark:${index}`,
913
+ label: mark.getAttribute("data-mpo-chart-mark") ?? "",
914
+ background: backdrop.hex,
915
+ unmeasurable: "unsupported-paint" as const,
916
+ }
917
+ : paintSample(
918
+ mark,
919
+ `${key}/mark:${index}`,
920
+ mark.getAttribute("data-mpo-chart-mark") || `mark ${index + 1}`,
921
+ );
922
+ sample.marks.push(item);
923
+ }
924
+ for (const [index, node] of Array.from(el.querySelectorAll("text, tspan")).entries()) {
925
+ // A text parent with tspan children owns no direct ink. Measure each
926
+ // actual text run once, including non-default SVG fill and alpha.
927
+ const text = Array.from(node.childNodes)
928
+ .filter((child) => child.nodeType === 3)
929
+ .map((child) => child.textContent ?? "")
930
+ .join("")
931
+ .trim();
932
+ if (text) sample.labels.push(paintSample(node, `${key}/label:${index}`, text));
933
+ }
934
+ for (const [index, drawable] of Array.from(
935
+ el.querySelectorAll(
936
+ "path, rect, circle, ellipse, line, polyline, polygon, image, foreignObject, use",
937
+ ),
938
+ ).entries()) {
939
+ if (drawable.hasAttribute("data-mpo-chart-mark")) continue;
940
+ const tag = drawable.tagName.toLowerCase();
941
+ const item = paintSample(drawable, `${key}/drawable:${index}`, `unaccounted ${tag}`);
942
+ // Transparent hit targets and definition geometry contribute no paint.
943
+ if (item.unmeasurable === "no-paint") continue;
944
+ const chrome = drawable.getAttribute("data-mpo-chart-chrome");
945
+ const style = getComputedStyle(drawable);
946
+ const rect = drawable.getBoundingClientRect();
947
+ const ctm = (drawable as SVGGraphicsElement).getScreenCTM?.();
948
+ const squareSum = ctm ? ctm.a ** 2 + ctm.b ** 2 + ctm.c ** 2 + ctm.d ** 2 : NaN;
949
+ const determinant = ctm ? ctm.a * ctm.d - ctm.b * ctm.c : NaN;
950
+ // Largest singular scale bounds stroke expansion under the complete
951
+ // screen transform, including skew and nonuniform ancestor scaling.
952
+ const largestScale = Math.sqrt(
953
+ (squareSum + Math.sqrt(Math.max(0, squareSum ** 2 - 4 * determinant ** 2))) / 2,
954
+ );
955
+ const screenStrokeWidth = Number.parseFloat(style.strokeWidth) * largestScale;
956
+ const thinAxisLine =
957
+ (chrome === "axis" || chrome === "grid") &&
958
+ tag === "line" &&
959
+ (rect.width === 0 || rect.height === 0) &&
960
+ Number.isFinite(screenStrokeWidth) &&
961
+ screenStrokeWidth <= 1 &&
962
+ screenStrokeWidth > 0 &&
963
+ !item.unmeasurable;
964
+ if (thinAxisLine) sample.chrome!.push({ ...item, label: `${chrome} line` });
965
+ else sample.unaccountedPaint!.push({ ...item, unmeasurable: "unsupported-paint" });
966
+ }
967
+ if (
968
+ sample.kind === "bar" &&
969
+ marks.length > 0 &&
970
+ marks.every(
971
+ (mark, index) =>
972
+ mark.tagName.toLowerCase() === "rect" &&
973
+ getComputedStyle(mark).stroke === "none" &&
974
+ !sample.marks[index].unmeasurable,
975
+ )
976
+ ) {
977
+ // AABB distance is a conservative lower bound on actual fill distance.
978
+ // All pairs, not only neighbors, must have the physical-pixel gap.
979
+ let minimumGapPx = Infinity;
980
+ const bounds = marks.map((mark) => mark.getBoundingClientRect());
981
+ for (let i = 0; i < bounds.length; i++)
982
+ for (let j = i + 1; j < bounds.length; j++) {
983
+ const a = bounds[i];
984
+ const b = bounds[j];
985
+ const dx = Math.max(0, a.left - b.right, b.left - a.right);
986
+ const dy = Math.max(0, a.top - b.bottom, b.top - a.bottom);
987
+ minimumGapPx = Math.min(minimumGapPx, Math.hypot(dx, dy) * window.devicePixelRatio);
988
+ }
989
+ sample.separation.method = "rect-bounds";
990
+ // With one mark there is no adjacent boundary to separate.
991
+ sample.separation.minimumGapPx = Number.isFinite(minimumGapPx) ? minimumGapPx : 2;
992
+ }
993
+ // Area fill sits behind the series stroke. A DOM ancestor backdrop walk
994
+ // cannot measure that composition, so no clean verdict is invented.
995
+ if (sample.kind === "area") sample.unmeasurable ??= "unsupported-overlap";
996
+ }
268
997
 
269
998
  /**
270
999
  * The element's own painted text, if any: the Range-measured rect of its
@@ -373,6 +1102,11 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
373
1102
  // Direct text nodes — the painted text, measured via Range (never the frame).
374
1103
  const textRect = collectText(el, path, cs);
375
1104
  if (textRect) entry.textRect = textRect;
1105
+ if (tag === "svg" && !el.parentElement?.closest("svg")) {
1106
+ capturedSvgRoots.add(el);
1107
+ if (el.hasAttribute("data-mpo-chart")) collectChart(el, path);
1108
+ else collectIcon(el, path, cs);
1109
+ }
376
1110
 
377
1111
  geometry.push(entry);
378
1112
 
@@ -395,9 +1129,21 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
395
1129
  counts[childTag] = index + 1;
396
1130
  visit(child, `${childTag}:${index}`);
397
1131
  }
1132
+ const svgRoots = Array.from(root.querySelectorAll("svg")).filter(
1133
+ (svg) => !svg.parentElement?.closest("svg"),
1134
+ );
1135
+ svgRootCount = svgRoots.length;
1136
+ // The geometry walk skips display:none subtrees. Those SVGs still need
1137
+ // a channel outcome: hidden icons are excluded, hidden charts unverified.
1138
+ for (const [index, svg] of svgRoots.entries()) {
1139
+ if (capturedSvgRoots.has(svg)) continue;
1140
+ const key = `unpainted-svg:${index}`;
1141
+ if (svg.hasAttribute("data-mpo-chart")) collectChart(svg, key);
1142
+ else collectIcon(svg, key, getComputedStyle(svg));
1143
+ }
398
1144
  }
399
1145
 
400
- return { storyRendered, geometry, textSamples };
1146
+ return { storyRendered, geometry, textSamples, iconSamples, chartSamples, svgRootCount };
401
1147
  }
402
1148
 
403
1149
  // ── Node-side comparators (pure, vitest-covered) ─────────────────────────────
@@ -525,6 +1271,220 @@ export function evaluateTextContrast(samples: MatrixTextSample[]): TextContrastR
525
1271
  return { measured, unmeasurable, violations };
526
1272
  }
527
1273
 
1274
+ /** A bounded single-ink SVG assertion. Unsupported compositions stay unverified. */
1275
+ export function evaluateIconContrast(samples: MatrixIconSample[]): IconContrastResult {
1276
+ const result: IconContrastResult = {
1277
+ ok: true,
1278
+ candidates: 0,
1279
+ measured: 0,
1280
+ violations: [],
1281
+ unverified: [],
1282
+ excludedDisabled: 0,
1283
+ excludedDecorative: 0,
1284
+ excludedHidden: 0,
1285
+ };
1286
+ for (const sample of samples) {
1287
+ if (sample.status === "disabled-control") {
1288
+ result.excludedDisabled++;
1289
+ continue;
1290
+ }
1291
+ if (sample.status === "decorative-standalone") {
1292
+ result.excludedDecorative++;
1293
+ continue;
1294
+ }
1295
+ if (sample.status === "hidden") {
1296
+ result.excludedHidden++;
1297
+ continue;
1298
+ }
1299
+ result.candidates++;
1300
+ const reason =
1301
+ sample.unmeasurable ??
1302
+ (!sample.backgroundResolved ? "unresolved-backdrop" : !sample.paint ? "no-paint" : undefined);
1303
+ if (reason) {
1304
+ result.unverified.push({ key: sample.key, context: sample.context, reason });
1305
+ continue;
1306
+ }
1307
+ try {
1308
+ const contrast = measureContrast({
1309
+ foreground: sample.paint!,
1310
+ background: sample.background,
1311
+ floor: minContrastRatio,
1312
+ label: sample.key,
1313
+ });
1314
+ result.measured++;
1315
+ if (!contrast.pass)
1316
+ result.violations.push({
1317
+ key: sample.key,
1318
+ context: sample.context,
1319
+ paint: contrast.foreground,
1320
+ background: contrast.background,
1321
+ ratio: contrast.ratio,
1322
+ required: contrast.floor,
1323
+ });
1324
+ } catch {
1325
+ result.unverified.push({
1326
+ key: sample.key,
1327
+ context: sample.context,
1328
+ reason: "unsupported-paint",
1329
+ });
1330
+ }
1331
+ }
1332
+ result.ok = result.violations.length === 0 && result.unverified.length === 0;
1333
+ return result;
1334
+ }
1335
+
1336
+ /** Stable named findings for both absolute cell failures and preset attribution. */
1337
+ export function iconContrastSignatures(result: IconContrastResult): string[] {
1338
+ return [
1339
+ ...result.violations.map(
1340
+ (item) =>
1341
+ `${item.key} (${item.context}) icon ${item.paint} on ${item.background} = ${item.ratio}:1 (floor ${item.required})`,
1342
+ ),
1343
+ ...result.unverified.map(
1344
+ (item) => `${item.key} (${item.context}) icon UNVERIFIED: ${item.reason}`,
1345
+ ),
1346
+ ];
1347
+ }
1348
+
1349
+ /** Bounded chart proof. Declarations route samples; measured paint decides. */
1350
+ export function evaluateChartProof(samples: MatrixChartSample[]): ChartProofResult {
1351
+ const result: ChartProofResult = {
1352
+ ok: true,
1353
+ candidates: samples.length,
1354
+ measured: 0,
1355
+ marksMeasured: 0,
1356
+ labelsMeasured: 0,
1357
+ byKind: Object.create(null),
1358
+ violations: [],
1359
+ unverified: [],
1360
+ };
1361
+ for (const sample of samples) {
1362
+ result.byKind[sample.kind] = (result.byKind[sample.kind] ?? 0) + 1;
1363
+ const unverified = (
1364
+ reason: ChartUnmeasurableReason,
1365
+ key = sample.key,
1366
+ context = sample.context,
1367
+ ) => result.unverified.push({ key, context, reason });
1368
+ if (!["bar", "pie", "donut", "line", "area"].includes(sample.kind)) {
1369
+ unverified("unknown-kind");
1370
+ continue;
1371
+ }
1372
+ if (!sample.label.trim())
1373
+ result.violations.push({ key: sample.key, context: sample.context, rule: "missing-label" });
1374
+ if (sample.unmeasurable) unverified(sample.unmeasurable);
1375
+ else if (!sample.backgroundResolved) unverified("unresolved-backdrop");
1376
+ if (!sample.marks.length) unverified("no-marks");
1377
+ for (const item of sample.unaccountedPaint ?? [])
1378
+ unverified("unsupported-paint", item.key, `${sample.context}: ${item.label}`);
1379
+ if (
1380
+ !sample.datumText.declared ||
1381
+ !sample.datumText.complete ||
1382
+ sample.datumText.expected <= 0 ||
1383
+ sample.datumText.observed !== sample.datumText.expected
1384
+ )
1385
+ unverified(
1386
+ "incomplete-datum-text",
1387
+ sample.key,
1388
+ sample.datumText.failure
1389
+ ? `${sample.context}: ${sample.datumText.failure}`
1390
+ : sample.context,
1391
+ );
1392
+ const compare = (
1393
+ paint: string,
1394
+ background: string,
1395
+ key: string,
1396
+ context: string,
1397
+ rule: ChartProofViolation["rule"],
1398
+ floor: number,
1399
+ ): boolean => {
1400
+ try {
1401
+ const measured = measureContrast({ foreground: paint, background, floor, label: key });
1402
+ if (!measured.pass)
1403
+ result.violations.push({
1404
+ key,
1405
+ context,
1406
+ rule,
1407
+ paint: measured.foreground,
1408
+ background: measured.background,
1409
+ ratio: measured.ratio,
1410
+ required: measured.floor,
1411
+ });
1412
+ return true;
1413
+ } catch {
1414
+ unverified("unsupported-paint", key, context);
1415
+ return false;
1416
+ }
1417
+ };
1418
+ for (const mark of sample.marks) {
1419
+ const context = `${sample.context}: ${mark.label}`;
1420
+ if (mark.unmeasurable || !mark.paint)
1421
+ unverified(mark.unmeasurable ?? "no-paint", mark.key, context);
1422
+ else if (
1423
+ compare(mark.paint, mark.background, mark.key, context, "mark-backdrop", minContrastRatio)
1424
+ )
1425
+ result.marksMeasured++;
1426
+ }
1427
+ for (const label of sample.labels) {
1428
+ const context = `${sample.context}: ${label.label}`;
1429
+ if (label.unmeasurable || !label.paint)
1430
+ unverified(label.unmeasurable ?? "no-paint", label.key, context);
1431
+ else if (
1432
+ compare(label.paint, label.background, label.key, context, "axis-text", aaTextContrastRatio)
1433
+ )
1434
+ result.labelsMeasured++;
1435
+ }
1436
+ const separated =
1437
+ ((sample.kind === "bar" && sample.separation.method === "rect-bounds") ||
1438
+ ((sample.kind === "pie" || sample.kind === "donut") &&
1439
+ sample.separation.method === "eroded-arc")) &&
1440
+ Number.isFinite(sample.separation.minimumGapPx) &&
1441
+ sample.separation.minimumGapPx! >= 2;
1442
+ if (sample.kind === "bar" && !separated) unverified("unverified-separation");
1443
+ if (sample.kind === "pie" || sample.kind === "donut") {
1444
+ if (sample.separation.declared && !separated) unverified("unverified-separation");
1445
+ if (sample.marks.length > 1 && !separated) {
1446
+ for (let i = 0; i < sample.marks.length; i++) {
1447
+ const first = sample.marks[i];
1448
+ const second = sample.marks[(i + 1) % sample.marks.length];
1449
+ // A two-mark chart has one unordered pair; larger pies also need
1450
+ // the cyclic final-to-first boundary.
1451
+ if (sample.marks.length === 2 && i === 1) continue;
1452
+ if (first.paint && second.paint && !first.unmeasurable && !second.unmeasurable)
1453
+ compare(
1454
+ first.paint,
1455
+ second.paint,
1456
+ `${first.key}~${second.key}`,
1457
+ `${sample.context}: ${first.label} / ${second.label}`,
1458
+ "adjacent-pair",
1459
+ minContrastRatio,
1460
+ );
1461
+ }
1462
+ }
1463
+ }
1464
+ if (
1465
+ !sample.unmeasurable &&
1466
+ sample.backgroundResolved &&
1467
+ sample.marks.length > 0 &&
1468
+ sample.marks.every((mark) => mark.paint && !mark.unmeasurable)
1469
+ )
1470
+ result.measured++;
1471
+ }
1472
+ result.ok = result.violations.length === 0 && result.unverified.length === 0;
1473
+ return result;
1474
+ }
1475
+
1476
+ export function chartProofSignatures(result: ChartProofResult): string[] {
1477
+ return [
1478
+ ...result.violations.map(
1479
+ (item) =>
1480
+ `${item.key} (${item.context}) chart ${item.rule}${item.ratio === undefined ? "" : ` ${item.paint} on ${item.background} = ${item.ratio}:1 (floor ${item.required})`}`,
1481
+ ),
1482
+ ...result.unverified.map(
1483
+ (item) => `${item.key} (${item.context}) chart UNVERIFIED: ${item.reason}`,
1484
+ ),
1485
+ ];
1486
+ }
1487
+
528
1488
  /** One matrix cell's no-breakage inputs (preset axis). */
529
1489
  export interface NoBreakageInput {
530
1490
  storyRendered: boolean;
@@ -546,6 +1506,14 @@ export interface NoBreakageInput {
546
1506
  baselineContrastViolations: number;
547
1507
  /** The DEFAULT-preset cell's contrast result, for naming the new misses. */
548
1508
  baselineContrast?: TextContrastResult;
1509
+ /**
1510
+ * Supply both for icon attribution. Existing text-only callers may omit both;
1511
+ * their verdict does not assert icon contrast. The matrix runner supplies both.
1512
+ */
1513
+ icons?: IconContrastResult;
1514
+ baselineIcons?: IconContrastResult;
1515
+ charts?: ChartProofResult;
1516
+ baselineCharts?: ChartProofResult;
549
1517
  }
550
1518
 
551
1519
  /**
@@ -554,11 +1522,12 @@ export interface NoBreakageInput {
554
1522
  * broke rather than by how many things broke.
555
1523
  */
556
1524
  export function auditSignatures(
557
- violations: Array<{ label: string; violations: string[] }>,
1525
+ violations: Array<{ key?: string; label: string; violations: string[] }>,
558
1526
  ): string[] {
559
1527
  const out: string[] = [];
560
1528
  for (const element of violations) {
561
- for (const violation of element.violations) out.push(`${element.label} :: ${violation}`);
1529
+ for (const violation of element.violations)
1530
+ out.push(`${element.key ? `[${element.key}] ` : ""}${element.label} :: ${violation}`);
562
1531
  }
563
1532
  return out;
564
1533
  }
@@ -601,30 +1570,54 @@ export function evaluateNoBreakage(input: NoBreakageInput): NoBreakageResult {
601
1570
  if (!input.storyRendered) {
602
1571
  failures.push("story did not render (empty story root)");
603
1572
  }
604
- if (input.offScaleViolations > input.baselineOffScaleViolations) {
605
- const added =
606
- input.offScaleSignatures && input.baselineOffScaleSignatures
607
- ? newEntries(input.baselineOffScaleSignatures, input.offScaleSignatures)
608
- : [];
1573
+ const structuralAdded =
1574
+ input.offScaleSignatures && input.baselineOffScaleSignatures
1575
+ ? newEntries(input.baselineOffScaleSignatures, input.offScaleSignatures)
1576
+ : [];
1577
+ if (structuralAdded.length || input.offScaleViolations > input.baselineOffScaleViolations) {
609
1578
  failures.push(
610
1579
  `constraint audit: ${input.offScaleViolations} off-scale violations ` +
611
- `(default cell has ${input.baselineOffScaleViolations})${namedList(added)}`,
1580
+ `(default cell has ${input.baselineOffScaleViolations})${namedList(structuralAdded)}`,
612
1581
  );
613
1582
  }
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
- : [];
1583
+ const signature = (violation: TextContrastViolation) =>
1584
+ `${violation.key} "${violation.text}" ${violation.color} on ${violation.background} = ` +
1585
+ `${violation.ratio}:1 (floor ${violation.required})`;
1586
+ const contrastAdded = input.baselineContrast
1587
+ ? newEntries(
1588
+ input.baselineContrast.violations.map(signature),
1589
+ input.contrast.violations.map(signature),
1590
+ )
1591
+ : [];
1592
+ if (contrastAdded.length || input.contrast.violations.length > input.baselineContrastViolations) {
624
1593
  failures.push(
625
1594
  `text contrast: ${input.contrast.violations.length} below-floor text nodes ` +
626
- `(default cell has ${input.baselineContrastViolations})${namedList(added)}`,
1595
+ `(default cell has ${input.baselineContrastViolations})${namedList(contrastAdded)}`,
1596
+ );
1597
+ }
1598
+ // Compare actual identities, including a replacement miss at equal counts.
1599
+ // Every cell also receives the absolute icons.ok gate in the matrix runner.
1600
+ if ((input.icons === undefined) !== (input.baselineIcons === undefined)) {
1601
+ failures.push("icon contrast: icons and baselineIcons must be supplied together");
1602
+ } else if (input.icons && input.baselineIcons) {
1603
+ const newIconFindings = newEntries(
1604
+ iconContrastSignatures(input.baselineIcons),
1605
+ iconContrastSignatures(input.icons),
1606
+ );
1607
+ if (newIconFindings.length > 0) {
1608
+ failures.push(
1609
+ `icon contrast: ${newIconFindings.length} new findings${namedList(newIconFindings)}`,
1610
+ );
1611
+ }
1612
+ }
1613
+ if ((input.charts === undefined) !== (input.baselineCharts === undefined)) {
1614
+ failures.push("chart proof: charts and baselineCharts must be supplied together");
1615
+ } else if (input.charts && input.baselineCharts) {
1616
+ const added = newEntries(
1617
+ chartProofSignatures(input.baselineCharts),
1618
+ chartProofSignatures(input.charts),
627
1619
  );
1620
+ if (added.length) failures.push(`chart proof: ${added.length} new findings${namedList(added)}`);
628
1621
  }
629
1622
  return { ok: failures.length === 0, failures };
630
1623
  }