diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index e11c50947b..2402abc0d2 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -220,6 +220,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; @@ -251,6 +254,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, @@ -259,6 +269,9 @@ window.__contrastAuditPrepare = function () { origColorPriority: origColorPriority, origFill: origFill, origFillPriority: origFillPriority, + origStrokeColor: origStrokeColor, + origStrokeColorPriority: origStrokeColorPriority, + hasStroke: !!stroke, isSvgText: isSvgText, }); @@ -266,6 +279,7 @@ window.__contrastAuditPrepare = function () { selector: selectorOf(el), text: (el.textContent || "").trim().slice(0, 50), fg: fg, + stroke: stroke, fontSize: fontSize, fontWeight: fontWeight, large: large, @@ -287,6 +301,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"); @@ -371,17 +394,25 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) { var stepY = Math.max(1, Math.floor((y1 - y0) / 6)); var rr = [], gg = [], - bb = []; + bb = [], + aa = []; for (var y = y0; y <= y1; y += stepY) { for (var x = x0; x <= x1; x += stepX) { var idx = (y * w + x) * 4; rr.push(px[idx]); gg.push(px[idx + 1]); bb.push(px[idx + 2]); + aa.push(px[idx + 3]); } } if (rr.length === 0) continue; + // A transparent sampled backdrop is supplied by the downstream editor, + // not this composition. WCAG contrast cannot be inferred until that live + // video/image is known, so do not invent an opaque browser default and + // hard-fail an otherwise valid alpha overlay. + if (median(aa) < 255) continue; + var bgR = median(rr), bgG = median(gg), bgB = median(bb); @@ -394,6 +425,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.js b/packages/cli/src/commands/layout-audit.browser.js index 1755e4973e..81ca084f96 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -105,6 +105,10 @@ return !!element.closest("[data-layout-allow-overflow]"); } + function hasTextClipOptOut(element) { + return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed"); + } + function opacityChain(element) { let opacity = 1; for (let current = element; current; current = current.parentElement) { @@ -394,6 +398,7 @@ } function clippedTextIssue(element, time, tolerance) { + if (hasTextClipOptOut(element)) return null; const style = getComputedStyle(element); if (!clipsOverflow(style)) return null; const overflowX = element.scrollWidth - element.clientWidth; @@ -455,7 +460,7 @@ const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance); if ( containerOverflow && - !hasAllowOverflowFlag(element) && + !hasTextClipOptOut(element) && !clippedByAncestor(element, container) ) { const style = elementStyle; @@ -481,7 +486,7 @@ } const canvasOverflow = overflowFor(textRect, rootRect, tolerance); - if (canvasOverflow && !hasAllowOverflowFlag(element)) { + if (canvasOverflow && !hasTextClipOptOut(element)) { issues.push({ code: "canvas_overflow", severity: "info", @@ -730,13 +735,117 @@ const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); + const imageAlphaCanvases = new WeakMap(); + + function objectPositionOffset(value, freeSpace) { + const token = String(value || "50%") + .trim() + .split(/\s+/)[0]; + if (token === "left" || token === "top") return 0; + if (token === "right" || token === "bottom") return freeSpace; + if (token === "center") return freeSpace / 2; + if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100; + const pixels = parseFloat(token); + return Number.isFinite(pixels) ? pixels : freeSpace / 2; + } + + function objectPositionOffsets(value, freeX, freeY) { + const tokens = String(value || "50% 50%") + .trim() + .split(/\s+/) + .slice(0, 2); + let x = "50%"; + let y = "50%"; + if (tokens.length === 1) { + if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0]; + else x = tokens[0]; + } else { + for (const token of tokens) { + if (token === "top" || token === "bottom") y = token; + else if (token === "left" || token === "right") x = token; + else if (x === "50%") x = token; + else y = token; + } + } + return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) }; + } + + // Return the alpha painted by an at a viewport point. `null` means the + // browser would not let us inspect the image (not loaded or cross-origin), in + // which case callers preserve the conservative opaque fallback. + function imageAlphaAt(element, x, y) { + const sourceWidth = element.naturalWidth; + const sourceHeight = element.naturalHeight; + const rect = element.getBoundingClientRect(); + if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null; + + const style = getComputedStyle(element); + const fit = style.objectFit || "fill"; + let scaleX = rect.width / sourceWidth; + let scaleY = rect.height / sourceHeight; + if (fit !== "fill") { + const contain = Math.min(scaleX, scaleY); + const cover = Math.max(scaleX, scaleY); + const scale = + fit === "cover" + ? cover + : fit === "none" + ? 1 + : fit === "scale-down" + ? Math.min(1, contain) + : contain; + scaleX = scale; + scaleY = scale; + } + + const paintedWidth = sourceWidth * scaleX; + const paintedHeight = sourceHeight * scaleY; + const offsets = objectPositionOffsets( + style.objectPosition, + rect.width - paintedWidth, + rect.height - paintedHeight, + ); + const localX = x - rect.left - offsets.x; + const localY = y - rect.top - offsets.y; + if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0; + + try { + let cached = imageAlphaCanvases.get(element); + const source = element.currentSrc || element.src; + if ( + !cached || + cached.width !== sourceWidth || + cached.height !== sourceHeight || + cached.source !== source + ) { + const canvas = document.createElement("canvas"); + canvas.width = sourceWidth; + canvas.height = sourceHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.drawImage(element, 0, 0, sourceWidth, sourceHeight); + cached = { context, width: sourceWidth, height: sourceHeight, source }; + imageAlphaCanvases.set(element, cached); + } + const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX))); + const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY))); + return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255; + } catch { + return null; + } + } // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid // background colour. Low-opacity overlays (grain, scrims) do not occlude. - function isOpaqueOccluder(element) { - if (opacityChain(element) < 0.6) return false; + function isOpaqueOccluder(element, x, y) { + const opacity = opacityChain(element); + if (opacity < 0.6) return false; if (IGNORE_TAGS.has(element.tagName)) return false; + if (element.tagName === "IMG") { + const alpha = imageAlphaAt(element, x, y); + if (alpha !== null) return alpha * opacity >= 0.6; + } if (RASTER_TAGS.has(element.tagName)) return true; return hasOpaqueBackground(getComputedStyle(element)); } @@ -798,7 +907,7 @@ // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders. if (sharedPreserve3d(element, hit)) continue; if (isCrossSceneTransitionOverlap(element, hit)) continue; - if (isOpaqueOccluder(hit)) return hit; + if (isOpaqueOccluder(hit, x, y)) return hit; } return null; } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 3034df101a..8494847fa4 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -141,6 +141,49 @@ describe("layout-audit.browser", () => { expect(runAudit()).toEqual([]); }); + it("suppresses intentional ellipsis clipping under overflow opt-outs", () => { + document.body.innerHTML = ` +
+
+
+ Intentional long truncated label +
+
+
+ `; + const headline = document.querySelector("#headline"); + if (!(headline instanceof HTMLElement)) throw new Error("missing headline"); + Object.defineProperties(headline, { + clientWidth: { configurable: true, value: 100 }, + scrollWidth: { configurable: true, value: 240 }, + clientHeight: { configurable: true, value: 20 }, + scrollHeight: { configurable: true, value: 20 }, + }); + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + headline: rect({ left: 40, top: 60, width: 100, height: 20 }), + text: rect({ left: 40, top: 60, width: 240, height: 20 }), + }, + { + headline: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" }, + }, + ); + + installAuditScript(); + const textOverflowCodes = () => + runAudit() + .map((issue) => issue.code) + .filter((code) => code === "clipped_text" || code === "text_box_overflow"); + + expect(textOverflowCodes()).toEqual(["clipped_text", "text_box_overflow"]); + document.querySelector("#overflow-optout")?.setAttribute("data-layout-allow-overflow", ""); + expect(textOverflowCodes()).toEqual([]); + document.querySelector("#overflow-optout")?.removeAttribute("data-layout-allow-overflow"); + headline.setAttribute("data-layout-bleed", "true"); + expect(textOverflowCodes()).toEqual([]); + }); + it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => { // A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug // line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize @@ -1089,6 +1132,75 @@ 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, + }); + }); + + it("skips text whose sampled backdrop remains transparent", async () => { + document.body.innerHTML = ` +
+ Live +
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(255, 255, 255)", + fontSize: "20px", + fontWeight: "700", + 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(new Uint8ClampedArray(640 * 360 * 4)); + + expect(await runContrastAudit()).toEqual([]); + }); }); // Both blocks overlap heavily; only the exemption on block A should suppress @@ -1206,6 +1318,24 @@ describe("layout-audit.browser occlusion", () => { expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("does not treat transparent pixels in an image as text occlusion", () => { + const issues = auditImageOcclusionScene(0); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("still treats opaque pixels in an image as text occlusion", () => { + const occluded = auditImageOcclusionScene(255).find((issue) => issue.code === "text_occluded"); + expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" }); + }); + + it("does not treat object-fit letterboxing as image occlusion", () => { + const issues = auditImageOcclusionScene(255, { + objectFit: "contain", + headlineTextRect: rect({ left: 50, top: 500, width: 200, height: 80 }), + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + it("respects the data-layout-allow-occlusion opt-out", () => { const issues = auditOcclusionScene({ headlineAttrs: "data-layout-allow-occlusion", @@ -1476,6 +1606,39 @@ function auditOcclusionScene(options: { return runAudit(); } +function auditImageOcclusionScene( + alpha: number, + options: { objectFit?: string; headlineTextRect?: DOMRect } = {}, +): ReturnType { + document.body.innerHTML = ` +
+
Headline copy
+ +
+ `; + const overlay = document.getElementById("overlay") as HTMLImageElement; + Object.defineProperties(overlay, { + naturalWidth: { configurable: true, value: 100 }, + naturalHeight: { configurable: true, value: 100 }, + }); + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as { + mockReturnValue(value: CanvasRenderingContext2D): void; + }; + getContextSpy.mockReturnValue({ + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, alpha]) })), + } as unknown as CanvasRenderingContext2D); + installOcclusionGeometry({ + styleOverrides: { overlay: { objectFit: options.objectFit ?? "fill" } }, + headlineTextRect: + options.headlineTextRect ?? rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + overlay.getBoundingClientRect = () => rect({ left: 0, top: 0, width: 1920, height: 1080 }); + installAuditScript(); + return runAudit(); +} + function installOcclusionGeometry(options: { styleOverrides: Record>>; headlineTextRect: DOMRect; diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 9624615717..2610aa725e 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -276,7 +276,11 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise typeof (window as unknown as Record).__contrastAuditFinish === "function" diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index e310db9c78..2722587623 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -510,7 +510,11 @@ async function collectContrast( // This screenshot is the one contrast math is sampled from below — it must // stay untouched by the annotation overlay (finishContrast reads real // painted pixels), so annotation only ever happens on a SECOND shot. - const measurementShot = await page.screenshot({ encoding: "base64", type: "png" }); + const measurementShot = await page.screenshot({ + encoding: "base64", + type: "png", + omitBackground: true, + }); if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64"); const raw = await finishContrast( page, diff --git a/skills-manifest.json b/skills-manifest.json index 18be5bca59..868882e205 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 14 }, "hyperframes-creative": { - "hash": "e0750456fba2cf22", + "hash": "826e4eafaedf8936", "files": 78 }, "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,