From a96b3cfc4d1bdafdd27297878847052716f676bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 18:06:53 +0000 Subject: [PATCH 1/2] fix(cli): account for text strokes in contrast checks --- .../src/commands/contrast-audit.browser.js | 40 +++++++++++++++++++ .../src/commands/layout-audit.browser.test.ts | 39 ++++++++++++++++++ .../scripts/contrast-report.mjs | 34 +++++++++++++++- 3 files changed, 111 insertions(+), 2 deletions(-) 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/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, From 886a03de97bf2375e12e19329bed2c813727bf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 18:15:00 +0000 Subject: [PATCH 2/2] chore(skills): sync contrast helper manifest --- skills-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {