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:
co-authored by
Claude Fable 5
parent
da537e68b8
commit
9aed2540aa
@@ -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 */ }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user