Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="label">Mixed color</div>
</div>
`;

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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
Expand Down
2 changes: 1 addition & 1 deletion skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"files": 14
},
"hyperframes-creative": {
"hash": "826e4eafaedf8936",
"hash": "8828d26924ae1519",
"files": 78
},
"hyperframes-keyframes": {
Expand Down
42 changes: 32 additions & 10 deletions skills/hyperframes-creative/scripts/contrast-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<text>, <tspan>, <textPath>) is painted via the `fill`
// property, not `color` — the two are independent CSS properties in
Expand All @@ -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;
Expand Down
Loading