Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
// extracting the shared scaffold would obscure what each case asserts.
"packages/cli/src/commands/skills.test.ts",
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
// rule (overflow / overlap / occlusion) that each install their own mocked
// geometry + computed-style before asserting. The clone groups are this
// pre-existing per-rule scaffold; adding a clip-path case shifts lines and
// re-flags it. Collapsing the per-rule installers would obscure each case.
"packages/cli/src/commands/layout-audit.browser.test.ts",
],
},
"health": {
Expand Down Expand Up @@ -409,6 +415,14 @@
// gsapRuntimeReaders.ts: pre-existing complexity in readAllAnimatedProperties;
// line-shift fingerprint from import-path updates triggers the violation.
"packages/studio/src/hooks/gsapRuntimeReaders.ts",
// In-page audit scripts: single large browser-side IIFE wrappers
// (auditLayout/__contrastAudit) with pre-existing CRITICAL helpers
// (textOverflowFixHint, selectorFor, the WCAG walk). Adding the small
// clip-path probe helpers shifts every function below it, so the line-shift
// fingerprint re-flags inherited complexity even though the new helpers are
// all at the lowest tier (<=5 cyclomatic).
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/commands/contrast-audit.browser.js",
],
},
}
40 changes: 40 additions & 0 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@ window.__contrastAudit = async function (imgBase64, time) {
return s[Math.floor(s.length / 2)];
}

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);
}

// Decode screenshot into canvas pixel data
var img = new Image();
await new Promise(function (resolve) {
Expand Down Expand Up @@ -110,6 +149,7 @@ window.__contrastAudit = async function (imgBase64, time) {
var rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) continue;
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
if (isClippedAway(el, rect)) continue;

var fg = parseColor(cs.color);
if (fg[3] <= 0.01) continue;
Expand Down
47 changes: 46 additions & 1 deletion packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,50 @@
return opacity;
}

// A clip-path can shrink an element's painted region to nothing (e.g. a
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
// its layout box, opacity, visibility and display all still read as present.
// Such an element paints zero pixels, so flagging it for overlap/occlusion is
// a false positive. clip-path also drives hit-testing, so an element clipped
// to nothing is unreachable by elementFromPoint anywhere in its box; only run
// the probe when a clip-path is actually in effect (self or ancestor) to avoid
// mistaking a genuinely-occluded element for a clipped one.
function hasClipPath(element) {
for (let current = element; current; current = current.parentElement) {
const clip = getComputedStyle(current).clipPath;
if (clip && clip !== "none") return true;
}
return false;
}

const CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
const CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];

function paintsAnyProbePoint(element, rect) {
// Probe resolution intentionally treats edge strips narrower than the
// nearest probe point as clipped away. That avoids noisy reports for
// typewriter pre-reveal states; if a real visible-strip bug appears, add
// edge probes here before widening the audit surface.
for (const fx of CLIP_PROBE_COLS) {
for (const fy of CLIP_PROBE_ROWS) {
const hit = document.elementFromPoint(
rect.left + rect.width * fx,
rect.top + rect.height * fy,
);
if (hit === element || element.contains(hit)) return true;
}
}
return false;
}

function isClippedAway(element) {
if (typeof document.elementFromPoint !== "function") return false;
if (!hasClipPath(element)) return false;
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
return !paintsAnyProbePoint(element, rect);
}

function isVisibleElement(element) {
if (IGNORE_TAGS.has(element.tagName)) return false;
if (hasIgnoreFlag(element)) return false;
Expand All @@ -111,7 +155,8 @@
}
if (opacityChain(element) < 0.2) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0.5 && rect.height > 0.5;
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
return !isClippedAway(element);
}

function textContentFor(element) {
Expand Down
123 changes: 121 additions & 2 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8");
const contrastScript = readFileSync(join(__dirname, "contrast-audit.browser.js"), "utf-8");

interface RectInput {
left: number;
Expand Down Expand Up @@ -140,6 +141,7 @@ describe("layout-audit.browser content overlap", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
});

Expand All @@ -166,6 +168,73 @@ describe("layout-audit.browser content overlap", () => {
it("respects the data-layout-allow-overlap opt-out", () => {
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
});

// A typewriter span clipped to nothing (clip-path: inset(0 100% 0 0)) keeps a
// normal box but paints zero pixels; overlapping it must not flag the visible
// block beneath. The clipped element is unreachable by elementFromPoint, which
// is how isClippedAway detects it.
it("excludes a block clipped to nothing by clip-path from overlap", () => {
const issues = auditOverlapScene({
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
b: {
textRect: rect({ left: 300, top: 120, width: 400, height: 100 }),
clipPath: "inset(0px 100% 0px 0px)",
},
});
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});

it("still flags overlap when clip-path leaves painted text visible", () => {
const issues = auditOverlapScene({
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
b: {
textRect: rect({ left: 300, top: 120, width: 400, height: 100 }),
clipPath: "inset(0px 25% 0px 0px)",
},
});
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
});
});

describe("contrast-audit.browser clip-path visibility", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.innerHTML = "";
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
delete (window as unknown as { __contrastAudit?: unknown }).__contrastAudit;
});

it("excludes text clipped to nothing by clip-path from contrast reports", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline">Hidden text</div>
</div>
`;

vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
return {
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(0, 0, 0)",
fontSize: "32px",
fontWeight: "400",
clipPath: id === "headline" ? "inset(0px 100% 0px 0px)" : "none",
} as unknown as CSSStyleDeclaration;
});

vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 100, top: 100, width: 400, height: 80 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;

installContrastScript();

expect(await runContrastAudit()).toEqual([]);
});
});

// Both blocks overlap heavily; only the exemption on block A should suppress
Expand All @@ -179,8 +248,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
}

function auditOverlapScene(options: {
a: { textRect: DOMRect; color?: string; attrs?: string };
b: { textRect: DOMRect; color?: string; attrs?: string };
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
Expand All @@ -192,6 +261,10 @@ function auditOverlapScene(options: {
a: options.a.color ?? "rgb(0, 0, 0)",
b: options.b.color ?? "rgb(0, 0, 0)",
};
const clipPaths: Record<string, string> = {
a: options.a.clipPath ?? "none",
b: options.b.clipPath ?? "none",
};
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };

vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
Expand All @@ -201,9 +274,18 @@ function auditOverlapScene(options: {
visibility: "visible",
opacity: "1",
color: colors[id] ?? "rgb(0, 0, 0)",
clipPath: clipPaths[id] ?? "none",
} as unknown as CSSStyleDeclaration;
});

// A clipped-to-nothing element is unreachable by elementFromPoint; mimic that
// by returning the topmost non-clipped block at any probe point.
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => {
if (!isFullyClipped(clipPaths.b ?? "none")) return document.getElementById("b");
if (!isFullyClipped(clipPaths.a ?? "none")) return document.getElementById("a");
return null;
};

for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
Expand All @@ -230,6 +312,10 @@ function auditOverlapScene(options: {
return runAudit();
}

function isFullyClipped(clipPath: string): boolean {
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
}

describe("layout-audit.browser occlusion", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -360,6 +446,39 @@ function installAuditScript(): void {
window.eval(script);
}

function installContrastScript(): void {
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 640;
naturalHeight = 360;

set src(_value: string) {
this.onload?.();
}
}

vi.stubGlobal("Image", MockImage);
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
},
} as unknown as CanvasRenderingContext2D);
window.eval(contrastScript);
}

async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
return (
window as unknown as {
__contrastAudit: (imgBase64: string, time: number) => Promise<Array<Record<string, unknown>>>;
}
).__contrastAudit("stub", 0);
}

function runAudit(): Array<{
code: string;
selector: string;
Expand Down
Loading