diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index 2402abc0d2..84f3cce42d 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -57,13 +57,39 @@ window.__contrastAuditPrepare = function () { return !!el.ownerSVGElement; } - 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()); + function tryParseCssColor(c) { + var rgb = c.match(/^rgba?\(([^)]+)\)$/i); + if (rgb) { + var rgbParts = rgb[1].trim().split(/[\s,\/]+/); + if (rgbParts.length < 3) return null; + var rgbValues = rgbParts.map(function (part, index) { + var value = parseFloat(part); + if (isNaN(value)) return NaN; + if (part.endsWith("%")) return index < 3 ? value * 2.55 : value / 100; + return value; + }); + if (rgbValues.some(isNaN)) return null; + return [rgbValues[0], rgbValues[1], rgbValues[2], rgbValues[3] ?? 1]; + } + + // Chromium preserves modern color-mix() results as color(srgb ...) + // instead of serializing them back to rgb()/rgba(). + var srgb = c.match(/^color\(srgb\s+([^)]*)\)$/i); + if (!srgb) return null; + var srgbParts = srgb[1].trim().split(/[\s\/]+/); + if (srgbParts.length < 3) return null; + var srgbValues = srgbParts.map(function (part, index) { + var value = parseFloat(part); + if (isNaN(value)) return NaN; + if (part.endsWith("%")) return index < 3 ? value * 2.55 : value / 100; + return index < 3 ? value * 255 : value; }); - return [p[0], p[1], p[2], p[3] != null ? p[3] : 1]; + if (srgbValues.some(isNaN)) return null; + return [srgbValues[0], srgbValues[1], srgbValues[2], srgbValues[3] ?? 1]; + } + + function parseColor(c) { + return tryParseCssColor(c) || [0, 0, 0, 1]; } // Like parseColor, but returns null instead of defaulting to black when the @@ -72,18 +98,7 @@ window.__contrastAuditPrepare = function () { // '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 p = m[1].split(",").map(function (s) { - return parseFloat(s.trim()); - }); - if ( - p.some(function (v) { - return isNaN(v); - }) - ) - return null; - return [p[0], p[1], p[2], p[3] != null ? p[3] : 1]; + return tryParseCssColor(c); } function selectorOf(el) { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 8494847fa4..9e3bca5bf4 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1133,6 +1133,42 @@ describe("contrast-audit.browser background sampling", () => { expect(result[0]).toMatchObject({ selector: "#label", wcagAA: true, bg: "rgb(10,10,10)" }); }); + it("parses modern color() output from color-mix() without fabricating black", async () => { + document.body.innerHTML = ` +
+
Mixed color
+
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "color(srgb 1 0 0 / 0.5)", + fontSize: "20px", + fontWeight: "400", + clipPath: "none", + }) as unknown as CSSStyleDeclaration, + ); + vi.spyOn(document.getElementById("label")!, "getBoundingClientRect").mockReturnValue( + rect({ left: 50, top: 50, width: 100, height: 30 }), + ); + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => + null; + + installContrastScript(); + + const result = await runContrastAudit(); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + selector: "#label", + fg: "rgb(255,128,128)", + bg: "rgb(255,255,255)", + }); + }); + it("accepts outlined text when its stroke has adequate background contrast", async () => { document.body.innerHTML = `
diff --git a/skills-manifest.json b/skills-manifest.json index 868882e205..b1c8fbd234 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "826e4eafaedf8936", + "hash": "8828d26924ae1519", "files": 78 }, "hyperframes-keyframes": { diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index 702490c149..8758bf3f5a 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -157,23 +157,44 @@ async function prepareTextElements(session) { // leaking hidden indefinitely. window.__contrastReportRestores = restores; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); - const parseColor = (c) => { - const m = c.match(/rgba?\(([^)]+)\)/); - if (!m) return [0, 0, 0, 1]; - const parts = m[1].split(",").map((s) => parseFloat(s.trim())); - return [parts[0], parts[1], parts[2], parts[3] ?? 1]; + const tryParseCssColor = (c) => { + const rgb = c.match(/^rgba?\(([^)]+)\)$/i); + if (rgb) { + const values = rgb[1] + .trim() + .split(/[\s,/]+/) + .map((part, index) => { + const value = parseFloat(part); + if (Number.isNaN(value)) return NaN; + if (part.endsWith("%")) return index < 3 ? value * 2.55 : value / 100; + return value; + }); + if (values.length < 3 || values.some(Number.isNaN)) return null; + return [values[0], values[1], values[2], values[3] ?? 1]; + } + + const srgb = c.match(/^color\(srgb\s+([^)]*)\)$/i); + if (!srgb) return null; + const values = srgb[1] + .trim() + .split(/[\s/]+/) + .map((part, index) => { + const value = parseFloat(part); + if (Number.isNaN(value)) return NaN; + if (part.endsWith("%")) return index < 3 ? value * 2.55 : value / 100; + return index < 3 ? value * 255 : value; + }); + if (values.length < 3 || values.some(Number.isNaN)) return null; + return [values[0], values[1], values[2], values[3] ?? 1]; }; + const parseColor = (c) => tryParseCssColor(c) || [0, 0, 0, 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. const tryParseSolidColor = (c) => { - const m = c.match(/rgba?\(([^)]+)\)/); - if (!m) return null; - const parts = m[1].split(",").map((s) => parseFloat(s.trim())); - if (parts.some((v) => Number.isNaN(v))) return null; - return [parts[0], parts[1], parts[2], parts[3] ?? 1]; + return tryParseCssColor(c); }; // SVG text (, , ) is painted via the `fill` // property, not `color` — the two are independent CSS properties in @@ -193,6 +214,7 @@ async function prepareTextElements(session) { (n) => n.nodeType === 3 && n.textContent.trim().length, ); if (!direct) continue; + if (el.closest?.("[data-layout-ignore]")) continue; const cs = getComputedStyle(el); if (cs.visibility === "hidden" || cs.display === "none") continue; if (parseFloat(cs.opacity) <= 0.01) continue;