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
40 changes: 40 additions & 0 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ window.__contrastAuditPrepare = function () {
var isSvgText = isSvgTextElement(el);
var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
if (fg[3] <= 0.01) continue;
var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
var stroke = strokeWidth > 0 ? tryParseSolidColor(cs.webkitTextStrokeColor || "") : null;
if (stroke && stroke[3] <= 0.01) stroke = null;

var fontSize = parseFloat(cs.fontSize);
var fontWeight = Number(cs.fontWeight) || 400;
Expand Down Expand Up @@ -241,6 +244,13 @@ window.__contrastAuditPrepare = function () {
origFillPriority = el.style.getPropertyPriority("fill");
el.style.setProperty("fill", "transparent", "important");
}
var origStrokeColor = null,
origStrokeColorPriority = null;
if (stroke) {
origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
}
restores.push({
el: el,
origTransition: origTransition,
Expand All @@ -249,13 +259,17 @@ window.__contrastAuditPrepare = function () {
origColorPriority: origColorPriority,
origFill: origFill,
origFillPriority: origFillPriority,
origStrokeColor: origStrokeColor,
origStrokeColorPriority: origStrokeColorPriority,
hasStroke: !!stroke,
isSvgText: isSvgText,
});

out.push({
selector: selectorOf(el),
text: (el.textContent || "").trim().slice(0, 50),
fg: fg,
stroke: stroke,
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
Expand All @@ -277,6 +291,15 @@ function __contrastAuditRestoreAll() {
if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
else r.el.style.removeProperty("fill");
}
if (r.hasStroke) {
if (r.origStrokeColor)
r.el.style.setProperty(
"-webkit-text-stroke-color",
r.origStrokeColor,
r.origStrokeColorPriority,
);
else r.el.style.removeProperty("-webkit-text-stroke-color");
}
if (r.origTransition)
r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
else r.el.style.removeProperty("transition");
Expand Down Expand Up @@ -384,6 +407,23 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {

var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);

// A solid text stroke is part of the visible glyph paint. Use whichever
// of fill or stroke provides the stronger edge contrast, rather than
// failing readable outlined captions based on fill alone.
if (c.stroke) {
var stroke = c.stroke;
var strokeR = Math.round(stroke[0] * stroke[3] + bgR * (1 - stroke[3]));
var strokeG = Math.round(stroke[1] * stroke[3] + bgG * (1 - stroke[3]));
var strokeB = Math.round(stroke[2] * stroke[3] + bgB * (1 - stroke[3]));
var strokeRatio = +wcagRatio(strokeR, strokeG, strokeB, bgR, bgG, bgB).toFixed(2);
if (strokeRatio > ratio) {
compR = strokeR;
compG = strokeG;
compB = strokeB;
ratio = strokeRatio;
}
}

out.push({
time: time,
selector: c.selector,
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,45 @@ describe("contrast-audit.browser background sampling", () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ selector: "#label", wcagAA: true, bg: "rgb(10,10,10)" });
});

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">
<div id="caption">Outlined white caption</div>
</div>
`;

vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255, 255, 255)",
webkitTextStrokeWidth: "8px",
webkitTextStrokeColor: "rgb(0, 0, 0)",
fontSize: "40px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("caption")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 300, height: 60 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;

installContrastScript();

const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#caption",
fg: "rgb(0,0,0)",
bg: "rgb(255,255,255)",
wcagAA: true,
});
});
});

// Both blocks overlap heavily; only the exemption on block A should suppress
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": "3e5bcbf46dc14427",
"hash": "b8cc06d395d059c5",
"files": 69
},
"hyperframes-keyframes": {
Expand Down
34 changes: 32 additions & 2 deletions skills/hyperframes-creative/scripts/contrast-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ async function prepareTextElements(session) {
? tryParseSolidColor(cs.fill) || parseColor(cs.color)
: parseColor(cs.color);
if (fg[3] <= 0.01) continue;
const strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
const stroke = strokeWidth > 0 ? tryParseSolidColor(cs.webkitTextStrokeColor || "") : null;

// A `transition` on color/fill would otherwise animate this hide
// instead of applying it instantly — the screenshot taken right after
Expand All @@ -222,6 +224,13 @@ async function prepareTextElements(session) {
origFillPriority = el.style.getPropertyPriority("fill");
el.style.setProperty("fill", "transparent", "important");
}
let origStrokeColor = null;
let origStrokeColorPriority = null;
if (stroke && stroke[3] > 0.01) {
origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
}
restores.push({
el,
origTransition,
Expand All @@ -230,13 +239,17 @@ async function prepareTextElements(session) {
origColorPriority,
origFill,
origFillPriority,
origStrokeColor,
origStrokeColorPriority,
hasStroke: !!stroke && stroke[3] > 0.01,
isSvgText,
});

out.push({
selector: selectorOf(el),
text: el.textContent.trim().slice(0, 60),
fg,
stroke: stroke && stroke[3] > 0.01 ? stroke : null,
fontSize: parseFloat(cs.fontSize),
fontWeight: Number(cs.fontWeight) || 400,
bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
Expand All @@ -257,6 +270,15 @@ async function restoreTextElements(session) {
if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
else r.el.style.removeProperty("fill");
}
if (r.hasStroke) {
if (r.origStrokeColor) {
r.el.style.setProperty(
"-webkit-text-stroke-color",
r.origStrokeColor,
r.origStrokeColorPriority,
);
} else r.el.style.removeProperty("-webkit-text-stroke-color");
}
if (r.origTransition) {
r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
} else r.el.style.removeProperty("transition");
Expand Down Expand Up @@ -285,8 +307,16 @@ async function measureAgainstHiddenTextFrame(hiddenImgBase64, candidates) {
for (const c of candidates) {
const bg = sampleBboxMedian(pixels, width, height, channels, c.bbox);
if (!bg) continue;
const fg = compositeOver(c.fg, bg); // flatten any alpha against measured bg
const ratio = wcagRatio(fg, bg);
let fg = compositeOver(c.fg, bg); // flatten any alpha against measured bg
let ratio = wcagRatio(fg, bg);
if (c.stroke) {
const stroke = compositeOver(c.stroke, bg);
const strokeRatio = wcagRatio(stroke, bg);
if (strokeRatio > ratio) {
fg = stroke;
ratio = strokeRatio;
}
}
const large = isLargeText(c.fontSize, c.fontWeight);
measured.push({
selector: c.selector,
Expand Down
Loading