From 250e271a6b27a82977980212ca03e11fd56b4b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 08:55:25 +0000 Subject: [PATCH 1/2] fix(cli): parse color-mix output in contrast audit --- .../src/commands/contrast-audit.browser.js | 51 ++++++++++++------- .../src/commands/layout-audit.browser.test.ts | 36 +++++++++++++ skills-manifest.json | 2 +- .../scripts/contrast-report.mjs | 42 +++++++++++---- 4 files changed, 102 insertions(+), 29 deletions(-) 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..ca88f39098 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "826e4eafaedf8936", + "hash": "a613b7986f938754", "files": 78 }, "hyperframes-keyframes": { diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs index 702490c149..9a46eb3dc2 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; From 087c5b0feaa80100ecb7adc6bfcf030fa6b69ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 08:58:30 +0000 Subject: [PATCH 2/2] chore(skills): satisfy contrast script lint --- skills-manifest.json | 2 +- skills/hyperframes-creative/scripts/contrast-report.mjs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index ca88f39098..b1c8fbd234 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "a613b7986f938754", + "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 9a46eb3dc2..8758bf3f5a 100644 --- a/skills/hyperframes-creative/scripts/contrast-report.mjs +++ b/skills/hyperframes-creative/scripts/contrast-report.mjs @@ -162,7 +162,7 @@ async function prepareTextElements(session) { if (rgb) { const values = rgb[1] .trim() - .split(/[\s,\/]+/) + .split(/[\s,/]+/) .map((part, index) => { const value = parseFloat(part); if (Number.isNaN(value)) return NaN; @@ -177,7 +177,7 @@ async function prepareTextElements(session) { if (!srgb) return null; const values = srgb[1] .trim() - .split(/[\s\/]+/) + .split(/[\s/]+/) .map((part, index) => { const value = parseFloat(part); if (Number.isNaN(value)) return NaN;