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
+67 -18
View File
@@ -19,7 +19,13 @@ import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } f
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
export type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
// Reveal families: transition (hidden via opacity/transform, revealed by the element's own
// transition) and visibility+entrance-class (hidden via visibility, revealed with a named
// keyframe animation — Elementor/WOW/AOS; the @keyframes ship in the page CSS).
export type RTReveal = {
cid: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
};
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
@@ -58,7 +64,16 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
const cid = map.get(rv.cap);
if (!ok(cid)) continue;
reveals.push({ cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition });
reveals.push({
cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition,
...(rv.visibility === "hidden" ? { visibility: rv.visibility } : {}),
...(rv.animationName ? {
animationName: rv.animationName,
animationDuration: rv.animationDuration,
animationDelay: rv.animationDelay,
animationTiming: rv.animationTiming,
} : {}),
});
}
const marquees: RTMarquee[] = [];
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
@@ -92,7 +107,10 @@ import { useEffect } from "react";
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
type RTReveal = {
cid: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
};
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
@@ -111,7 +129,8 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
const intervals: ReturnType<typeof setInterval>[] = [];
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
const anims: Animation[] = [];
const revealed: Array<() => void> = []; // per-reveal "show now" fns (also the cleanup)
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
const revealed: Array<(animate: boolean) => void> = [];
let io: IntersectionObserver | null = null;
let forceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -153,29 +172,59 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
}
// Scroll reveals: hide each element (opacity/transform) with the captured transition,
// then reveal (clear the inline overrides → transitions to the base CSS) when it scrolls
// into view. A force-reveal timer guarantees nothing stays hidden if the observer misses.
// Scroll reveals: re-hide each element (JS-applied on mount, so non-JS/SSR still shows
// the content), then reveal when it scrolls into view. Two families:
// - transition — hide via opacity/transform, reveal by transitioning to full;
// - visibility+entrance-class (Elementor/WOW/AOS) — hide via visibility with the baked
// entrance animation suppressed, reveal by restarting the captured @keyframes
// (they ship in the page CSS under the captured animation-name).
// A force-reveal timer guarantees nothing stays hidden if the observer misses.
if (spec.reveals.length) {
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
const show = (el: HTMLElement) => { el.style.opacity = "1"; el.style.transform = "none"; };
const byEl = new Map<Element, HTMLElement>();
// animate=false (validator settle path) jumps straight to the settled frame so no
// measurement can catch a mid-entrance value.
const show = (el: HTMLElement, rv: RTReveal, animate: boolean) => {
el.style.opacity = "1"; el.style.transform = "none";
if (rv.visibility === "hidden") el.style.visibility = "visible";
if (rv.animationName) {
if (animate) {
// restart the entrance from t=0: none -> name starts a fresh animation
el.style.animationName = "none";
void el.offsetWidth;
el.style.animationName = rv.animationName;
el.style.animationDuration = rv.animationDuration || "1s";
el.style.animationDelay = rv.animationDelay || "0s";
el.style.animationTimingFunction = rv.animationTiming || "ease";
el.style.animationFillMode = "both";
el.style.animationIterationCount = "1";
} else {
el.style.animationName = "none"; // settled frame: keep the entrance suppressed
}
}
};
const shows = new Map<Element, (animate: boolean) => void>();
for (const rv of spec.reveals) {
const el = byCid(rv.cid);
if (!el) continue;
el.style.transition = rv.transition;
el.style.opacity = rv.opacity;
if (rv.transform !== "none") el.style.transform = rv.transform;
byEl.set(el, el);
revealed.push(() => show(el));
if (rv.visibility === "hidden") {
el.style.visibility = "hidden";
el.style.animationName = "none"; // don't burn the baked entrance while hidden
} else {
el.style.transition = rv.transition;
el.style.opacity = rv.opacity;
if (rv.transform !== "none") el.style.transform = rv.transform;
}
const fn = (animate: boolean) => show(el, rv, animate);
shows.set(el, fn);
revealed.push(fn);
}
io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { const el = byEl.get(e.target); if (el) { show(el); io!.unobserve(e.target); } }
for (const e of entries) if (e.isIntersecting) { const f = shows.get(e.target); if (f) { f(true); io!.unobserve(e.target); } }
}, { rootMargin: "0px 0px -8% 0px" });
for (const el of byEl.keys()) io.observe(el);
forceTimer = setTimeout(() => { for (const f of revealed) f(); }, 4000);
for (const el of shows.keys()) io.observe(el);
forceTimer = setTimeout(() => { for (const f of revealed) f(true); }, 4000);
}
const stopAll = () => {
@@ -185,7 +234,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
if (io) io.disconnect();
if (forceTimer) clearTimeout(forceTimer);
for (const f of revealed) f(); // reveal everything → base CSS settled frame
for (const f of revealed) f(false); // reveal everything, settled → base CSS graded frame
};
// Measurement hook: restore the fully-settled/revealed base for grading.
(window as any).__dittoMotionStop = stopAll;