mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
460 lines
19 KiB
JavaScript
460 lines
19 KiB
JavaScript
// Browser-side WCAG contrast audit.
|
|
// Loaded as a raw string and injected via page.addScriptTag to avoid
|
|
// esbuild mangling (page.evaluate serializes functions; __name helpers break).
|
|
//
|
|
// Two-phase API — see packages/cli/src/commands/validate.ts's
|
|
// runContrastAudit() for the calling contract:
|
|
//
|
|
// 1. window.__contrastAuditPrepare() walks the DOM for text-bearing
|
|
// elements, computes each one's foreground paint (CSS `color`, or SVG
|
|
// `fill` for SVG text — fill and color are independent CSS properties
|
|
// in SVG), and HIDES that element's own text paint (color/fill set to
|
|
// transparent). Hiding is layout-neutral — it doesn't reflow anything —
|
|
// so the caller can screenshot the frame right after with the glyphs
|
|
// invisible but everything else unchanged. Returns the candidate list
|
|
// (selector, text, fg, bbox, font metrics).
|
|
// 2. Caller takes ONE screenshot (page.screenshot()) — same number of
|
|
// screenshots as before, just moved to after prepare() instead of
|
|
// before it.
|
|
// 3. window.__contrastAuditFinish(imgBase64, time, candidates) restores
|
|
// the original paint FIRST (so a slow/failed decode can't leave the
|
|
// page's text stuck invisible), then decodes the screenshot and, for
|
|
// each candidate, samples the real composited pixels directly INSIDE
|
|
// its own bounding box for the true background.
|
|
//
|
|
// Why sample inside the box instead of the 4px ring just outside it (the
|
|
// previous approach): the ring is a proximity heuristic that's wrong
|
|
// whenever what's immediately outside the text differs from what's actually
|
|
// behind it — a neighboring panel/component just past the text's edge, a
|
|
// rounded pill/button whose corner falls inside the ring, a
|
|
// backdrop-filter-blurred glass panel sized only a couple pixels larger
|
|
// than the text, or a translucent decoration that only partially overlaps
|
|
// the ring (or sits entirely inside the bbox, never touching the ring at
|
|
// all). Hiding the glyphs and sampling their own box side-steps all of
|
|
// that: it reads the exact pixels that were behind them.
|
|
//
|
|
// window.__contrastAuditRestoreIfPending() is a safety net: if the caller's
|
|
// screenshot or finish() call throws between prepare() and finish(), calling
|
|
// this restores any still-hidden paint so the next sample in the loop
|
|
// doesn't audit a page with stale invisible text. It's a no-op after a
|
|
// normal finish() call.
|
|
//
|
|
// NOTE: this logic (DOM-walk, foreground/paint-hide, background sampling)
|
|
// plus the pure WCAG math (relLum, wcagRatio, median) is duplicated in
|
|
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep in sync.
|
|
// The pure "which rect to sample" decision is also mirrored in
|
|
// contrast-sample.ts (unit-tested there since this file can't import).
|
|
|
|
/* eslint-disable */
|
|
window.__contrastAuditPrepare = function () {
|
|
// SVG text (<text>, <tspan>, <textPath>) is painted via the `fill`
|
|
// property, not `color` — the two are independent CSS properties in SVG.
|
|
// A page can set `fill` (inline style, `fill` attribute, or a CSS rule)
|
|
// without ever touching `color`, in which case getComputedStyle(el).color
|
|
// resolves to the inherited/initial value (often black) and does not
|
|
// reflect what's actually rendered on screen.
|
|
function isSvgTextElement(el) {
|
|
return !!el.ownerSVGElement;
|
|
}
|
|
|
|
function parseColor(c) {
|
|
var m = (c || "").match(/^rgba?\(([^)]+)\)$/i);
|
|
if (!m) {
|
|
var mix = (c || "").match(
|
|
/^color-mix\(\s*in\s+srgb\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*\)$/i,
|
|
);
|
|
if (!mix) return null;
|
|
var first = parseColor(mix[1]);
|
|
var second = parseColor(mix[3]);
|
|
var firstWeight = parseFloat(mix[2]);
|
|
var secondWeight = parseFloat(mix[4]);
|
|
var weightTotal = firstWeight + secondWeight;
|
|
if (!first || !second || !isFinite(weightTotal) || weightTotal <= 0) return null;
|
|
return first.map(function (channel, index) {
|
|
return (channel * firstWeight + second[index] * secondWeight) / weightTotal;
|
|
});
|
|
}
|
|
var p = m[1].split(",").map(function (s) {
|
|
return parseFloat(s.trim());
|
|
});
|
|
if (
|
|
(p.length !== 3 && p.length !== 4) ||
|
|
p.some(function (v) {
|
|
return !isFinite(v);
|
|
})
|
|
)
|
|
return null;
|
|
return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
|
|
}
|
|
|
|
function selectorOf(el) {
|
|
if (el.id) return "#" + el.id;
|
|
var cls = Array.from(el.classList).slice(0, 2).join(".");
|
|
return cls ? el.tagName.toLowerCase() + "." + cls : el.tagName.toLowerCase();
|
|
}
|
|
|
|
function hasClipPath(el) {
|
|
for (var ce = el; ce; ce = ce.parentElement) {
|
|
var cp = getComputedStyle(ce).clipPath;
|
|
if (cp && cp !== "none") return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
var CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
|
|
var CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
|
|
|
|
function paintsAnyProbePoint(el, rect) {
|
|
// Keep probe resolution aligned with layout-audit.browser.js. Edge strips
|
|
// narrower than the nearest probe point are treated as clipped away to
|
|
// avoid noisy typewriter pre-reveal contrast reports.
|
|
for (var ci = 0; ci < CLIP_PROBE_COLS.length; ci++) {
|
|
for (var ri = 0; ri < CLIP_PROBE_ROWS.length; ri++) {
|
|
var x = rect.left + rect.width * CLIP_PROBE_COLS[ci];
|
|
var y = rect.top + rect.height * CLIP_PROBE_ROWS[ri];
|
|
var hit = document.elementFromPoint(x, y);
|
|
if (hit === el || el.contains(hit)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// A clip-path can shrink an element's painted region to nothing (a typewriter
|
|
// span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while its box and
|
|
// colours read normally; it then paints zero pixels and measures a meaningless
|
|
// background-on-background ratio. clip-path drives hit-testing, so a fully
|
|
// clipped element is unreachable by elementFromPoint across its box. Probe only
|
|
// when a clip-path is in effect (self or ancestor) so genuinely-occluded but
|
|
// unclipped text is not skipped.
|
|
function isClippedAway(el, rect) {
|
|
if (typeof document.elementFromPoint !== "function") return false;
|
|
if (!hasClipPath(el)) return false;
|
|
return !paintsAnyProbePoint(el, rect);
|
|
}
|
|
|
|
function isIntentionallyOccluded(el, rect) {
|
|
if (typeof document.elementFromPoint !== "function") return false;
|
|
if (!el.closest || !el.closest("[data-layout-allow-occlusion]")) return false;
|
|
return !paintsAnyProbePoint(el, rect);
|
|
}
|
|
|
|
var out = [];
|
|
var restores = [];
|
|
// Registered BEFORE the walk starts (not after it finishes) and pushed to
|
|
// incrementally as each element is hidden: if getComputedStyle/
|
|
// getBoundingClientRect/etc. throws partway through the walk (e.g. on a
|
|
// detached or otherwise pathological element), everything hidden before
|
|
// the throw is still reachable for restore instead of leaking hidden
|
|
// indefinitely.
|
|
window.__contrastAuditRestores = restores;
|
|
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
var node;
|
|
while ((node = walker.nextNode())) {
|
|
var el = node;
|
|
|
|
// Must have a direct text node child
|
|
var hasText = false;
|
|
for (var i = 0; i < el.childNodes.length; i++) {
|
|
if (
|
|
el.childNodes[i].nodeType === 3 &&
|
|
(el.childNodes[i].textContent || "").trim().length > 0
|
|
) {
|
|
hasText = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasText) continue;
|
|
|
|
// Same decorative opt-out the layout audit honors: text marked (or inside)
|
|
// data-layout-ignore is set dressing, not copy a viewer must read —
|
|
// deliberately dim rail labels, ghost typography, texture text.
|
|
if (el.closest && el.closest("[data-layout-ignore]")) continue;
|
|
|
|
// Text that has (nearly) left the canvas — a cursor exiting the frame, an
|
|
// element parked off-screen — is not readable content, and sampling its
|
|
// clamped edge reads whatever pixels happen to sit at the border (the
|
|
// classic false "white-on-white"). Require a minimally-visible on-canvas
|
|
// intersection before judging contrast; the layout audit separately owns
|
|
// off-canvas detection as its own finding class.
|
|
var vis = el.getBoundingClientRect();
|
|
var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0);
|
|
var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0);
|
|
if (onX < 8 || onY < 8) continue;
|
|
|
|
var cs = getComputedStyle(el);
|
|
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
|
if (parseFloat(cs.opacity) <= 0.01) continue;
|
|
// Also skip when an ANCESTOR is effectively invisible (opacity≈0 / hidden / display:none).
|
|
// Karaoke captions keep every word at opacity 1 but toggle the GROUP's opacity per beat,
|
|
// so an inactive word's OWN opacity is 1 — only an ancestor reveals it's hidden. Without
|
|
// this, the hidden caption words flood the audit with false ~1:1 contrast warnings.
|
|
var anc = el.parentElement,
|
|
ancHidden = false;
|
|
while (anc && anc !== document.body) {
|
|
var acs = getComputedStyle(anc);
|
|
if (
|
|
acs.visibility === "hidden" ||
|
|
acs.display === "none" ||
|
|
parseFloat(acs.opacity) <= 0.01
|
|
) {
|
|
ancHidden = true;
|
|
break;
|
|
}
|
|
anc = anc.parentElement;
|
|
}
|
|
if (ancHidden) continue;
|
|
var rect = el.getBoundingClientRect();
|
|
if (rect.width < 8 || rect.height < 8) continue;
|
|
if (rect.right <= 0 || rect.bottom <= 0) continue;
|
|
if (isClippedAway(el, rect)) continue;
|
|
// The layout audit's explicit occlusion opt-out means this text is allowed
|
|
// to sit behind another scene. Skip contrast only while every probe point
|
|
// is actually covered; the same copy is audited normally when visible.
|
|
if (isIntentionallyOccluded(el, rect)) continue;
|
|
|
|
// For SVG text, `fill` is the paint that's actually rendered; `color` is
|
|
// frequently just the inherited/initial value and unrelated to what's on
|
|
// screen. Only trust `fill` when it resolves to a solid color — "none",
|
|
// "context-fill", and gradient/pattern refs (url(#...)) fall back to
|
|
// `color` rather than crashing parseColor or reporting a fabricated
|
|
// black.
|
|
var isSvgText = isSvgTextElement(el);
|
|
var fg = isSvgText ? parseColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
|
|
if (!fg) continue;
|
|
if (fg[3] <= 0.01) continue;
|
|
var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
|
|
var stroke = strokeWidth > 0 ? parseColor(cs.webkitTextStrokeColor || "") : null;
|
|
if (stroke && stroke[3] <= 0.01) stroke = null;
|
|
|
|
var fontSize = parseFloat(cs.fontSize);
|
|
var fontWeight = Number(cs.fontWeight) || 400;
|
|
var large = fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
|
|
|
|
// Hide this element's OWN text paint so the caller's next screenshot
|
|
// reveals the true pixels behind the glyphs. Layout-neutral: color/fill
|
|
// never affect box geometry. `!important` beats any non-!important rule
|
|
// that might otherwise win on specificity; we restore the exact prior
|
|
// inline value (or remove the property entirely) afterward.
|
|
//
|
|
// A `transition` on color/fill would otherwise animate this hide instead
|
|
// of applying it instantly — the caller's screenshot lands in the same
|
|
// task-queue gap as the transition's own frames, so it can catch a
|
|
// partially-transparent (still partly the original color) glyph instead
|
|
// of a fully hidden one, contaminating the background sample. Force
|
|
// `transition: none` alongside color/fill so the hide is atomic, and
|
|
// restore it alongside them.
|
|
var origTransition = el.style.getPropertyValue("transition");
|
|
var origTransitionPriority = el.style.getPropertyPriority("transition");
|
|
el.style.setProperty("transition", "none", "important");
|
|
var origColor = el.style.getPropertyValue("color");
|
|
var origColorPriority = el.style.getPropertyPriority("color");
|
|
el.style.setProperty("color", "transparent", "important");
|
|
var origFill = null,
|
|
origFillPriority = null;
|
|
if (isSvgText) {
|
|
origFill = el.style.getPropertyValue("fill");
|
|
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,
|
|
origTransitionPriority: origTransitionPriority,
|
|
origColor: origColor,
|
|
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,
|
|
bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
|
});
|
|
}
|
|
|
|
return out;
|
|
};
|
|
|
|
function __contrastAuditRestoreAll() {
|
|
var restores = window.__contrastAuditRestores;
|
|
if (!restores) return;
|
|
for (var i = 0; i < restores.length; i++) {
|
|
var r = restores[i];
|
|
if (r.origColor) r.el.style.setProperty("color", r.origColor, r.origColorPriority);
|
|
else r.el.style.removeProperty("color");
|
|
if (r.isSvgText) {
|
|
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");
|
|
}
|
|
window.__contrastAuditRestores = null;
|
|
}
|
|
|
|
// Safety net for the caller: if the screenshot or finish() call throws
|
|
// between prepare() and finish(), call this to restore any still-hidden
|
|
// paint so the next sample in the loop isn't auditing a page with stale
|
|
// invisible text. No-op if finish() already ran normally.
|
|
window.__contrastAuditRestoreIfPending = function () {
|
|
__contrastAuditRestoreAll();
|
|
};
|
|
|
|
window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
|
|
function relLum(r, g, b) {
|
|
function ch(v) {
|
|
var s = v / 255;
|
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
}
|
|
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
|
|
}
|
|
|
|
function wcagRatio(r1, g1, b1, r2, g2, b2) {
|
|
var l1 = relLum(r1, g1, b1),
|
|
l2 = relLum(r2, g2, b2);
|
|
var hi = l1 > l2 ? l1 : l2,
|
|
lo = l1 > l2 ? l2 : l1;
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
}
|
|
|
|
function median(arr) {
|
|
var s = arr.slice().sort(function (a, b) {
|
|
return a - b;
|
|
});
|
|
return s[Math.floor(s.length / 2)];
|
|
}
|
|
|
|
// Restore original paint first — we already have the screenshot, and we
|
|
// never want a decode failure below to leave the page's text invisible.
|
|
__contrastAuditRestoreAll();
|
|
|
|
var img = new Image();
|
|
await new Promise(function (resolve) {
|
|
img.onload = resolve;
|
|
img.onerror = function () {
|
|
resolve();
|
|
};
|
|
img.src = "data:image/png;base64," + imgBase64;
|
|
});
|
|
if (!img.naturalWidth) return [];
|
|
var canvas = document.createElement("canvas");
|
|
canvas.width = img.naturalWidth || 1920;
|
|
canvas.height = img.naturalHeight || 1080;
|
|
var ctx = canvas.getContext("2d");
|
|
if (!ctx) return [];
|
|
ctx.drawImage(img, 0, 0);
|
|
var px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
var w = canvas.width;
|
|
var h = canvas.height;
|
|
|
|
var out = [];
|
|
for (var ci = 0; ci < candidates.length; ci++) {
|
|
var c = candidates[ci];
|
|
var bbox = c.bbox;
|
|
|
|
// Sample the element's OWN box (glyphs are hidden in this screenshot),
|
|
// inset 1px on each side to dodge anti-aliased edge pixels, clamped to
|
|
// the canvas. Mirrors contrast-sample.ts's computeSampleRect.
|
|
var x0 = Math.max(0, Math.round(bbox.x) + 1);
|
|
var x1 = Math.min(w - 1, Math.round(bbox.x + bbox.w) - 1);
|
|
var y0 = Math.max(0, Math.round(bbox.y) + 1);
|
|
var y1 = Math.min(h - 1, Math.round(bbox.y + bbox.h) - 1);
|
|
if (x1 <= x0 || y1 <= y0) continue;
|
|
|
|
// Bounded grid, not a full scan — dense enough to catch a
|
|
// partially-overlapping decoration without turning a wide caption bar
|
|
// into thousands of samples. Mirrors contrast-sample.ts's
|
|
// sampleGridPoints.
|
|
var stepX = Math.max(1, Math.floor((x1 - x0) / 12));
|
|
var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
|
|
var rr = [],
|
|
gg = [],
|
|
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);
|
|
|
|
// Composite foreground alpha over the measured background
|
|
var fg = c.fg;
|
|
var compR = Math.round(fg[0] * fg[3] + bgR * (1 - fg[3]));
|
|
var compG = Math.round(fg[1] * fg[3] + bgG * (1 - fg[3]));
|
|
var compB = Math.round(fg[2] * fg[3] + bgB * (1 - fg[3]));
|
|
|
|
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,
|
|
text: c.text,
|
|
ratio: ratio,
|
|
wcagAA: c.large ? ratio >= 3 : ratio >= 4.5,
|
|
large: c.large,
|
|
fg: "rgb(" + compR + "," + compG + "," + compB + ")",
|
|
bg: "rgb(" + bgR + "," + bgG + "," + bgB + ")",
|
|
});
|
|
}
|
|
return out;
|
|
};
|