diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js
index 0aa7efe505..d4f7d1c171 100644
--- a/packages/cli/src/commands/contrast-audit.browser.js
+++ b/packages/cli/src/commands/contrast-audit.browser.js
@@ -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;
@@ -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,
@@ -249,6 +259,9 @@ window.__contrastAuditPrepare = function () {
origColorPriority: origColorPriority,
origFill: origFill,
origFillPriority: origFillPriority,
+ origStrokeColor: origStrokeColor,
+ origStrokeColorPriority: origStrokeColorPriority,
+ hasStroke: !!stroke,
isSvgText: isSvgText,
});
@@ -256,6 +269,7 @@ window.__contrastAuditPrepare = function () {
selector: selectorOf(el),
text: (el.textContent || "").trim().slice(0, 50),
fg: fg,
+ stroke: stroke,
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
@@ -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");
@@ -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,
diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts
index 1a334a5b68..138a28af42 100644
--- a/packages/cli/src/commands/layout-audit.browser.test.ts
+++ b/packages/cli/src/commands/layout-audit.browser.test.ts
@@ -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 = `
+
+
Outlined white caption
+
+ `;
+
+ 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
diff --git a/skills-manifest.json b/skills-manifest.json
index 80fc0fe017..ccfb1f1739 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -34,7 +34,7 @@
"files": 14
},
"hyperframes-creative": {
- "hash": "3e5bcbf46dc14427",
+ "hash": "b8cc06d395d059c5",
"files": 69
},
"hyperframes-keyframes": {
diff --git a/skills/hyperframes-creative/scripts/contrast-report.mjs b/skills/hyperframes-creative/scripts/contrast-report.mjs
index 1f7db29355..702490c149 100644
--- a/skills/hyperframes-creative/scripts/contrast-report.mjs
+++ b/skills/hyperframes-creative/scripts/contrast-report.mjs
@@ -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
@@ -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,
@@ -230,6 +239,9 @@ async function prepareTextElements(session) {
origColorPriority,
origFill,
origFillPriority,
+ origStrokeColor,
+ origStrokeColorPriority,
+ hasStroke: !!stroke && stroke[3] > 0.01,
isSvgText,
});
@@ -237,6 +249,7 @@ async function prepareTextElements(session) {
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 },
@@ -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");
@@ -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,