mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(cli): consolidate layout and contrast audit correctness (#2401)
* fix(cli): respect transparent image pixels in occlusion audit * test(cli): cover contained image letterboxing * fix(cli): account for text strokes in contrast checks * fix(cli): honor text overflow opt-outs * fix(cli): skip contrast on transparent backdrops * chore(skills): refresh generated manifest
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 <img> 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;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,49 @@ describe("layout-audit.browser", () => {
|
||||
expect(runAudit()).toEqual([]);
|
||||
});
|
||||
|
||||
it("suppresses intentional ellipsis clipping under overflow opt-outs", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="overflow-optout">
|
||||
<div id="headline" style="width: 100px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
|
||||
Intentional long truncated label
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<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,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips text whose sampled backdrop remains transparent", async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="overlay" data-width="640" data-height="360">
|
||||
<span id="label">Live</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">Headline copy</div>
|
||||
<img id="overlay" src="paper.png" alt="" />
|
||||
</div>
|
||||
`;
|
||||
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<string, Partial<Record<string, string>>>;
|
||||
headlineTextRect: DOMRect;
|
||||
|
||||
@@ -276,7 +276,11 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
|
||||
: [],
|
||||
)) as ContrastCandidate[];
|
||||
|
||||
const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
|
||||
const screenshot = (await page.screenshot({
|
||||
encoding: "base64",
|
||||
type: "png",
|
||||
omitBackground: true,
|
||||
})) as string;
|
||||
const entries = await page.evaluate(
|
||||
(b64: string, time: number, cands: ContrastCandidate[]) =>
|
||||
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user