fix(check): resolve color-mix() colors in contrast audit instead of false-failing (#2445)

This commit is contained in:
Miguel Ángel
2026-07-14 15:51:11 -04:00
committed by GitHub
parent 990f5c3145
commit 6c2513d5c8
2 changed files with 65 additions and 19 deletions
@@ -58,28 +58,29 @@ window.__contrastAuditPrepare = function () {
}
function parseColor(c) {
var m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return [0, 0, 0, 1];
var p = m[1].split(",").map(function (s) {
return parseFloat(s.trim());
});
return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
}
// Like parseColor, but returns null instead of defaulting to black when the
// value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords such as
// "none"/"context-fill", or a gradient/pattern reference like
// 'url("#grad")'. Callers should fall back to another source of truth
// rather than trust a fabricated black.
function tryParseSolidColor(c) {
var m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
var m = (c || "").match(/^rgba?\(([^)]+)\)$/i);
if (!m) {
var mix = (c || "").match(
/^color-mix\(\s*in\s+srgb\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*\)$/i,
);
if (!mix) return null;
var first = parseColor(mix[1]);
var second = parseColor(mix[3]);
var firstWeight = parseFloat(mix[2]);
var secondWeight = parseFloat(mix[4]);
var weightTotal = firstWeight + secondWeight;
if (!first || !second || !isFinite(weightTotal) || weightTotal <= 0) return null;
return first.map(function (channel, index) {
return (channel * firstWeight + second[index] * secondWeight) / weightTotal;
});
}
var p = m[1].split(",").map(function (s) {
return parseFloat(s.trim());
});
if (
(p.length !== 3 && p.length !== 4) ||
p.some(function (v) {
return isNaN(v);
return !isFinite(v);
})
)
return null;
@@ -218,10 +219,11 @@ window.__contrastAuditPrepare = function () {
// `color` rather than crashing parseColor or reporting a fabricated
// black.
var isSvgText = isSvgTextElement(el);
var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
var fg = isSvgText ? parseColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
if (!fg) continue;
if (fg[3] <= 0.01) continue;
var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
var stroke = strokeWidth > 0 ? tryParseSolidColor(cs.webkitTextStrokeColor || "") : null;
var stroke = strokeWidth > 0 ? parseColor(cs.webkitTextStrokeColor || "") : null;
if (stroke && stroke[3] <= 0.01) stroke = null;
var fontSize = parseFloat(cs.fontSize);
@@ -1133,6 +1133,50 @@ describe("contrast-audit.browser background sampling", () => {
expect(result[0]).toMatchObject({ selector: "#label", wcagAA: true, bg: "rgb(10,10,10)" });
});
it("resolves color-mix() foregrounds before computing contrast", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<span id="label">Mixed color</span>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "color-mix(in srgb, rgb(37, 99, 235) 20%, rgb(255, 255, 255) 80%)",
fontSize: "20px",
fontWeight: "400",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("label")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 120, height: 30 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript(
pixelsWithRegion(
rect({ left: 0, top: 0, width: 640, height: 360 }),
[10, 10, 10],
[10, 10, 10],
),
);
const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#label",
fg: "rgb(211,224,251)",
bg: "rgb(10,10,10)",
ratio: 14.92,
wcagAA: true,
});
});
it("accepts outlined text when its stroke has adequate background contrast", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">