Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes

Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
  capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
  markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
  generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
  (collapsed sections, viewport-inconsistent captures); settle autoplay
  carousels to home slide before every snapshot; force-reveal hidden
  videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
  picture>source through IR prune with srcset rewrite (mobile-crop-on-
  desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
  forms) with element-screenshot fallback; asset download retry +
  visual-assets-missing reporting

Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 17:45:42 -07:00
co-authored by Claude Fable 5
parent da537e68b8
commit 9aed2540aa
31 changed files with 2579 additions and 121 deletions
+64 -16
View File
@@ -41,7 +41,16 @@ export type RevealSpec = {
cap: string; // data-cid-cap of a scroll-revealed element
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
transform: string; // its hidden transform (slide/scale offset), or "none"
transition: string; // the transition to animate the reveal with
transition: string; // the transition to animate the reveal with ("" for the visibility family)
// visibility+entrance-class family (Elementor/WOW/AOS): hidden via `visibility:hidden`
// pre-scroll, revealed by a class swap that applies a keyframe animation. The clone
// re-hides with visibility (JS-applied, so non-JS/SSR still shows content) and replays
// the named animation when scrolled into view.
visibility?: "hidden";
animationName?: string; // entrance @keyframes name in the revealed state (e.g. fadeInUp)
animationDuration?: string; // e.g. "1.25s"
animationDelay?: string; // e.g. "0s"
animationTiming?: string; // e.g. "ease" / "cubic-bezier(...)"
};
export type MarqueeSpec = {
@@ -62,26 +71,41 @@ export type MotionCapture = {
};
/**
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden
* (opacity:0 / transform offset) and animate in when scrolled into view; by the time the
* settled snapshot is taken (after `autoScroll` has walked the page) they are already
* revealed, so their hidden state must be sampled BEFORE the first scroll. Records, on
* `window.__cloneReveal`, the pre-scroll opacity/transform/transition of every tagged
* element that is hidden-but-boxed with a real transition — the candidate reveal set
* `captureMotion` later confirms (kept only if the element ends up visible). Idempotent;
* call once at the first viewport, after settle, before `autoScroll`.
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden and
* animate in when scrolled into view; by the time the settled snapshot is taken (after
* the reveal-settling pass has walked the page) they are already revealed, so their
* hidden state must be sampled BEFORE the first scroll. Records, on `window.__cloneReveal`,
* two candidate families over tagged elements — the set `captureMotion` later confirms
* (kept only if the element ends up visible):
* - **transition** — opacity≈0 with a real opacity/transform transition (the reveal
* animates via the transition already on the element);
* - **visibility** — `visibility:hidden` with a real box (Elementor `.elementor-invisible`,
* WOW/AOS wrappers), revealed by a class swap that APPLIES a keyframe animation. The
* entrance animation only exists post-swap, so `captureMotion` reads it at confirm
* time from the revealed computed style. Only the OUTERMOST hidden element is recorded
* (visibility inherits — descendants are covered by the wrapper's reveal).
* Idempotent; call once at the canonical width, after settle, before the first scroll.
*/
export async function probeReveals(page: Page): Promise<void> {
try {
await page.evaluate(() => {
const out: Record<string, { opacity: string; transform: string; transition: string }> = {};
const out: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> = {};
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { continue; }
const op = parseFloat(cs.opacity || "1");
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
if (cs.visibility === "hidden") {
// visibility family: record only the reveal ROOT (parent not also hidden).
const p = (el as HTMLElement).parentElement;
let parentHidden = false;
try { parentHidden = !!p && getComputedStyle(p).visibility === "hidden"; } catch { /* ignore */ }
if (parentHidden) continue;
out[cap] = { opacity: cs.opacity || "1", transform: "none", transition: "", family: "visibility" };
continue;
}
const op = parseFloat(cs.opacity || "1");
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
const tp = (cs.transitionProperty || "").toLowerCase();
const td = cs.transitionDuration || "0s";
@@ -311,17 +335,41 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
const reveals: Array<{ cap: string; opacity: string; transform: string; transition: string }> = [];
const reveals: Array<{
cap: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
}> = [];
try {
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string }> }).__cloneReveal || {};
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> }).__cloneReveal || {};
// First value of a comma-joined animation longhand; timing functions carry inner
// commas (cubic-bezier/steps), so take the whole leading function when present.
const first = (v: string): string => (/^\s*(cubic-bezier\([^)]*\)|steps\([^)]*\)|[^,]+)/.exec(v || "")?.[1] ?? "").trim();
for (const cap of Object.keys(probed)) {
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
if (!el) continue;
const cs = getComputedStyle(el);
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
const p = probed[cap]!;
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
reveals.push({ cap, ...probed[cap]! });
if (p.family === "visibility") {
if (cs.visibility === "hidden") continue; // never revealed → genuinely hidden content
// Revealed via class swap. The swap's entrance animation is now in the computed
// style (libraries keep the animated class); record it for replay.
const name = first(cs.animationName || "none");
reveals.push({
cap, opacity: p.opacity, transform: "none", transition: "",
visibility: "hidden",
...(name && name !== "none" ? {
animationName: name,
animationDuration: first(cs.animationDuration) || "1s",
animationDelay: first(cs.animationDelay) || "0s",
animationTiming: first(cs.animationTimingFunction) || "ease",
} : {}),
});
continue;
}
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
reveals.push({ cap, opacity: p.opacity, transform: p.transform, transition: p.transition });
}
} catch { /* ignore */ }