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 = `
+
+ `;
+
+ 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;