@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.
- package/package.json +5 -5
- package/src/audit/constraintAudit.spec.ts +157 -0
- package/src/audit/constraintAudit.ts +154 -35
- package/src/audit/constraintScope.spec.ts +48 -0
- package/src/audit/index.ts +16 -0
- package/src/audit/themeMatrix.spec.ts +292 -1
- package/src/audit/themeMatrix.ts +1015 -22
- package/src/theme/Intent.stories.tsx +88 -0
- package/src/theme/Preset.stories.tsx +80 -0
- package/src/theme/Surface.spec.tsx +6 -0
- package/src/theme/Surface.stories.tsx +91 -0
- package/src/theme/ThemeProvider.stories.tsx +50 -0
- package/src/theme/Tint.stories.tsx +98 -0
- package/src/theme/chartPalette.spec.ts +128 -5
- package/src/theme/chartPalette.ts +105 -22
- package/src/theme/colorRules.spec.ts +92 -39
- package/src/theme/colorRules.ts +4 -6
- package/src/theme/createDefaultThemeConfig.ts +1 -1
- package/src/theme/createThemes.ts +58 -3
- package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
- package/src/theme/glyphPaint.spec.ts +14 -0
- package/src/theme/glyphPaint.ts +2 -1
- package/src/theme/intent.spec.tsx +1 -0
- package/src/theme/layoutTokensHooks.spec.tsx +1 -0
- package/src/theme/recipeInputs.ts +9 -9
- package/src/theme/resolveKnobs.spec.ts +2 -2
- package/src/theme/sizeLadder.spec.ts +19 -10
- package/src/theme/sizeRecipes.ts +2 -2
- package/src/theme/themeValue.spec.ts +2 -2
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
- package/types/audit/constraintAudit.d.ts +17 -1
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/index.d.ts +2 -2
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +135 -2
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +6 -4
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +3 -3
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/glyphPaint.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +8 -8
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +2 -2
package/src/audit/themeMatrix.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
input.
|
|
607
|
-
|
|
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(
|
|
1580
|
+
`(default cell has ${input.baselineOffScaleViolations})${namedList(structuralAdded)}`,
|
|
612
1581
|
);
|
|
613
1582
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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(
|
|
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
|
}
|