Popup dismissal hardening: shadow roots, overlay units, scroll-lock recovery
- Overlay detection resolves effective z-index through stacking ancestors, treats a zero-size max-z wrapper plus its full-viewport descendant as one removable unit, pierces open shadow roots, and relaxes the z floor when the page is scroll-locked; a lock persisting after dismissal reports blocking even with no detected overlay - clickDismiss runs in every frame (close buttons live in cross-origin popup iframes), matches decline/no-thanks/aria-label close affordances, and re-runs before every snapshot AND screenshot to catch late-mounting dialogs - rootUnclamp triggers from IR content extent (a scroll-lock collapses captured scrollHeight) and strips vendor-injected body position/overflow locks when firing - Pollution gate fails on the scroll-lock contradiction: scrollHeight pinned to one viewport while IR content spans multiple - Frame graft skips popup-creative vendor hosts; inline embedded signup forms remain graftable (regression-tested) 322 tests pass (24 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
23505ee229
commit
0dd71a68d9
@@ -1,5 +1,5 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { isTextChild, irContentExtent } from "../normalize/ir.js";
|
||||
import type { PageSnapshot } from "../capture/walker.js";
|
||||
import type { GenNode } from "./render.js";
|
||||
import { indexByCid } from "./render.js";
|
||||
@@ -119,11 +119,30 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
let blocking = capture.dismissal?.blocking ?? false;
|
||||
for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; }
|
||||
let minHeightRatio = Infinity;
|
||||
let maxHeightRatio = 0;
|
||||
for (const pv of capture.perViewport) {
|
||||
if (pv.height > 0) minHeightRatio = Math.min(minHeightRatio, pv.scrollHeight / pv.height);
|
||||
if (pv.height > 0) {
|
||||
const ratio = pv.scrollHeight / pv.height;
|
||||
minHeightRatio = Math.min(minHeightRatio, ratio);
|
||||
maxHeightRatio = Math.max(maxHeightRatio, ratio);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1;
|
||||
|
||||
// Scroll-locked-capture contradiction: an email-capture/promo popup that sets
|
||||
// body{overflow:hidden;height:100vh} collapses `document.scrollHeight` to EXACTLY the viewport
|
||||
// height at EVERY width (ratio ~1.0 across the board) — yet the real page's IN-FLOW content
|
||||
// (the IR's sections) still lays out several viewports tall. A genuine one-screen landing page
|
||||
// has content extent ~= its scrollHeight, so this only fires when the two disagree: captured
|
||||
// scrollHeight pinned to one viewport WHILE the IR content spans multiple. That is a
|
||||
// scroll-locked, polluted capture — the overlay detector should have caught it, so fail loudly.
|
||||
let maxContentRatio = 0;
|
||||
for (const pv of capture.perViewport) {
|
||||
if (pv.height > 0) maxContentRatio = Math.max(maxContentRatio, irContentExtent(ir.root, pv.viewport) / pv.height);
|
||||
}
|
||||
// scrollHeight never exceeds ~1 viewport at any width, but the IR content is 2+ viewports tall.
|
||||
const scrollLockedContradiction = maxHeightRatio > 0 && maxHeightRatio < 1.15 && maxContentRatio >= 2;
|
||||
|
||||
// Degenerate signals. Calibrated against real captures: an egress/bot wall is
|
||||
// ~3 nodes / ~24 chars; the most minimal legitimate page in the suite
|
||||
// (michaelcole.me) is 26 nodes / 640 chars. Thresholds sit safely between.
|
||||
@@ -131,6 +150,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
if (wall && nodeCount < 220) issues.push("bot/egress wall text on a small page");
|
||||
if (textChars < 60 && nodeCount < 50) issues.push(`near-empty page: ${textChars} visible text chars, ${nodeCount} nodes`);
|
||||
if (blocking) issues.push("a full-viewport modal still scroll-locks the page after dismissal");
|
||||
if (scrollLockedContradiction) issues.push(`scroll-locked capture: scrollHeight pinned to ~1 viewport at every width while IR content spans ${round2(maxContentRatio)} viewports`);
|
||||
|
||||
return {
|
||||
gate: "pollution",
|
||||
@@ -138,6 +158,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
metrics: {
|
||||
nodeCount, visibleTextChars: textChars, wallTextDetected: wall,
|
||||
overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio),
|
||||
maxScrollHeightRatio: round2(maxHeightRatio), maxContentExtentRatio: round2(maxContentRatio),
|
||||
dismissedCount: capture.dismissal?.dismissed.length ?? 0,
|
||||
overlaysRemoved: capture.dismissal?.removed ?? 0,
|
||||
videoStills: capture.dismissal?.videoStills ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user