+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compiler/fixtures/placeholder.html b/compiler/fixtures/placeholder.html
new file mode 100644
index 0000000..5df2d76
--- /dev/null
+++ b/compiler/fixtures/placeholder.html
@@ -0,0 +1,18 @@
+
+
+
+
+Placeholder styling
+
+
+
+
+
+
+
+
diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts
index da15fca..1257259 100644
--- a/compiler/src/capture/capture.ts
+++ b/compiler/src/capture/capture.ts
@@ -3,6 +3,14 @@ import { join } from "node:path";
import { collectPage, type PageSnapshot, type FontFace } from "./walker.js";
import { tagElements, captureInteractions, type InteractionCapture } from "./interactions.js";
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
+import {
+ promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
+ forceRevealForShot, restoreRevealForShot,
+} from "./stabilize.js";
+import {
+ enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
+ MAX_GRAFT_FRAMES, FRAME_GRAFT_MAX_NODES, type FrameCandidate,
+} from "./graft.js";
import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js";
@@ -90,6 +98,23 @@ function viewportHeight(width: number): number {
return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
}
+// ---- Asset download retry (Fix: transient failures silently degrade to placeholders) ----
+
+/** Types whose absence is visible in the clone (image → transparent GIF, video → blank,
+ * font → fallback face). These earn one retry; css/manifest/lottie degrade gracefully. */
+const RETRYABLE_ASSET_TYPES = new Set(["image", "svg", "video", "font"]);
+/** Fixed, bounded delay before the single retry — deterministic (no jitter/backoff). */
+export const ASSET_RETRY_DELAY_MS = 750;
+
+/** Should a failed download of `type` with HTTP `status` (null = network error / no
+ * response) be retried once? Transient states only: connection failures, 5xx, and 429.
+ * 4xx (404/403/…) are authoritative — retrying can't change them. */
+export function isRetryableAssetFailure(type: string, status: number | null): boolean {
+ if (!RETRYABLE_ASSET_TYPES.has(type)) return false;
+ if (status === null) return true;
+ return status >= 500 || status === 429;
+}
+
function extFromUrl(url: string): string {
try {
const p = new URL(url).pathname;
@@ -137,6 +162,19 @@ function isCss(url: string, contentType: string | null): boolean {
return classifyAsset(url, contentType) === "css";
}
+/** Container-magic check for accepted video bytes: ISO-BMFF (`ftyp` within the first
+ * 12 bytes — mp4/m4v/mov), webm/mkv (EBML 0x1A45DFA3), or ogg (`OggS`). A range
+ * fragment (e.g. an mp4's moov-atom tail served as a 206) fails all three, so a
+ * corrupt partial body is never stored as the asset. */
+export function looksLikeVideoFile(bytes: Buffer): boolean {
+ if (bytes.length < 12) return false;
+ const head = bytes.subarray(0, 12);
+ if (head.includes("ftyp")) return true;
+ if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return true; // EBML (webm/mkv)
+ if (head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) return true; // "OggS"
+ return false;
+}
+
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise {
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
// to the top so document-coordinate bboxes are measured from a settled layout.
@@ -535,6 +573,10 @@ export async function captureSite(opts: {
const storeBytes = (url: string, type: string, bytes: Buffer): void => {
if (!bytes || bytes.length === 0) return;
+ // Reject non-container bytes for video from EVERY path (a range fragment or error
+ // page stored under first-stored-wins ships a corrupt file). Left unstored, the
+ // asset surfaces in visual_assets_missing instead.
+ if (type === "video" && !looksLikeVideoFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
@@ -655,6 +697,10 @@ export async function captureSite(opts: {
const existing = assetMap.get(url);
if (existing?.storedAs) return;
if (status >= 400) return;
+ // A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
+ // ship a corrupt file under first-stored-wins and the full-download fallback
+ // would then skip the asset. Record only; the fallback pass fetches the 200 body.
+ if (status === 206) return;
bodyPromises.push(
(async () => {
try {
@@ -684,6 +730,42 @@ export async function captureSite(opts: {
if (!navigated) throw navErr;
await settle(page);
+ // Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in
+ // `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver
+ // (collapsed sections in the snapshot) and the interaction pass can trigger the swap
+ // midway (viewports then DISAGREE about the section's size). Promote once, before any
+ // snapshot, so every viewport measures the same loaded media.
+ const lazyPromoted = await promoteLazyMedia(page);
+ if (lazyPromoted) {
+ log({ event: "lazy_promoted", count: lazyPromoted });
+ await settle(page, 2000); // newly-real images reflow the layout
+ }
+
+ // Stage 2: reveal settling. Scroll reveals (Elementor waypoints, WOW/AOS class swaps)
+ // are the same class of one-shot load-state as lazy media: fire them ONCE, before any
+ // viewport snapshot, so every width records the POST-REVEAL steady state — otherwise
+ // the snapshot bakes `visibility:hidden` wrappers (or a mid-fade opacity) with no JS
+ // to ever reveal them, and the clone renders below-fold content blank.
+ // A scroll-locking consent wall would defeat the dwell walk, so clear it first (the
+ // per-viewport dismissal below still runs and owns the audit trail).
+ const preClicked = await clickDismiss(page);
+ if (preClicked.length) {
+ for (const d of preClicked) if (!dismissUnion.dismissed.includes(d)) dismissUnion.dismissed.push(d);
+ await settle(page, 1000);
+ }
+ // Motion capture needs the PRE-reveal hidden state, observable only before the first
+ // scroll — tag + probe now (captureMotion confirms the candidates at the canonical
+ // snapshot, reading each revealed element's entrance animation from computed style).
+ if (opts.motion) { await tagElements(page); await probeReveals(page); }
+ const revealSettle = await settleScrollReveals(page);
+ log({ event: "reveals_settled", ...revealSettle });
+ // Belt-and-braces: reveal any element STILL carrying a known-library pre-reveal marker
+ // (below the step bound, or keyed to a non-scroll trigger) via the library's own
+ // revealed state, so captured computed styles are genuine post-reveal values.
+ const neutralized = await neutralizePreReveal(page);
+ if (neutralized) log({ event: "prereveal_neutralized", count: neutralized });
+ await settle(page, 1500); // revealed content reflows the layout
+
for (const vw of viewports) {
const vh = viewportHeight(vw);
await page.setViewportSize({ width: vw, height: vh });
@@ -710,12 +792,8 @@ export async function captureSite(opts: {
};
await applyDismiss("load");
- // Stage 5 (scroll reveals): at the FIRST viewport, before any scroll, tag elements
- // and snapshot the pre-reveal hidden state — scroll reveals fire on the first
- // autoScroll and stay revealed, so this is the only moment their hidden state is
- // observable. Idempotent + motion-gated; the settled snapshot is unchanged.
- if (opts.motion && vw === viewports[0]) { await tagElements(page); await probeReveals(page); }
-
+ // (Scroll-reveal probing happens once, before the viewport loop — see the reveal
+ // settling pass above; by this point all one-shot reveals have already fired.)
await autoScroll(page, vh);
await settle(page, 1500);
await applyDismiss("post-scroll");
@@ -737,6 +815,15 @@ export async function captureSite(opts: {
});
await page.waitForTimeout(150);
+ // Stage 2: settle autoplaying carousels (pause + navigate to the home slide)
+ // before EVERY snapshot — otherwise each viewport freezes a different track
+ // offset (per-band CSS then bakes four different slides), and the interaction
+ // pass between the canonical and last snapshots would leave the final viewport
+ // contaminated. Scoped to named-library tracks so motion.ts's marquee/rotator
+ // capture (which runs after the canonical snapshot) observes them unchanged.
+ const carousels = await settleCarousels(page);
+ if (carousels.roots) log({ event: "carousels_settled", viewport: vw, ...carousels });
+
// Stage 2: dynamic-media first frame. Materialize a still for poster-less
// videos so they don't render blank — canvas where the frame is readable, else
// an element screenshot (the page cannot screenshot itself). Done once at the
@@ -755,12 +842,27 @@ export async function captureSite(opts: {
} catch { /* ignore */ }
}
for (const s of plan.shots) {
+ // A visibility:hidden video (entrance animation not yet fired) passes the size
+ // gate, but locator.screenshot auto-waits for visibility and times out —
+ // force-reveal the hidden ancestor chain for the shot, restore exactly after.
+ let shot = false;
try {
+ await page.evaluate(forceRevealForShot, s.sel);
const buf = await page.locator(s.sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
storeBytes(s.url, "image", buf);
dismissUnion.videoStills++;
- } catch { /* element not shootable (off-screen/detached) — poster falls back to placeholder */ }
+ shot = true;
+ } catch (e) {
+ log({ event: "video_still_error", viewport: vw, sel: s.sel, error: String(e).slice(0, 200) });
+ } finally {
+ await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
+ }
+ // A synthetic poster with no bytes behind it generates a transparent tile —
+ // on failure, remove the attr so the video renders as it did pre-capture.
+ if (!shot) {
+ await page.evaluate((sel) => { document.querySelector(sel)?.removeAttribute("poster"); }, s.sel).catch(() => { /* ignore */ });
+ }
}
// Element screenshots scroll the target into view; restore scroll 0 so the
// canonical DOM walk + screenshot match the generated app's default render.
@@ -774,6 +876,95 @@ export async function captureSite(opts: {
// them (whitelisted), enabling interaction deltas + motion specs to map to cids.
if ((opts.interactions || opts.motion) && vw === canonical) await tagElements(page);
+ // Stage 2.6: cross-origin iframe content. The in-page walker cannot see into a
+ // cross-origin frame (newsletter/form embeds), but Node CAN evaluate in it — run the
+ // SAME collectPage per meaningful frame here, then graft each subtree into this
+ // viewport's snapshot below (graft.ts). Frames that can't be grafted (media players,
+ // dead frames) get an element-screenshot recorded as the iframe's background at the
+ // canonical viewport, so the box at least PAINTS. Runs before the main collectPage so
+ // the fallback's inline background is part of the canonical computed style.
+ const frameCands: FrameCandidate[] = await Promise.race([
+ page.evaluate(enumerateFramesInPage),
+ new Promise((res) => setTimeout(() => res([]), 8000)),
+ ]).catch(() => [] as FrameCandidate[]);
+ const frameGrafts: Array<{ cand: FrameCandidate; snap: PageSnapshot }> = [];
+ let graftBudget = MAX_GRAFT_FRAMES;
+ let stillBudget = MAX_GRAFT_FRAMES; // fallback stills share the same per-page bound
+ let frameShotScrolled = false;
+ for (const cand of frameCands) {
+ if (!cand.visible) continue;
+ const plan = planForFrameUrl(cand.url);
+ if (plan === "skip") continue;
+ let frameSnap: PageSnapshot | null = null;
+ if (plan === "graft" && graftBudget > 0) {
+ try {
+ const handle = await page.$(`iframe[data-ditto-frame="${cand.idx}"]`);
+ const frame = handle ? await handle.contentFrame() : null;
+ if (frame) {
+ await frame.evaluate(ESBUILD_SHIM);
+ frameSnap = await Promise.race([
+ frame.evaluate(collectPage, { maxNodes: FRAME_GRAFT_MAX_NODES }),
+ new Promise((_, rej) => setTimeout(() => rej(new Error("frame collect timeout")), 20_000)),
+ ]);
+ }
+ await handle?.dispose();
+ } catch (e) {
+ log({ event: "frame_graft_error", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160), error: String(e).slice(0, 200) });
+ frameSnap = null;
+ }
+ }
+ if (frameSnap && frameHasRenderableContent(frameSnap.root)) {
+ graftBudget--;
+ frameGrafts.push({ cand, snap: frameSnap });
+ // Merge the frame's discoveries exactly like the main document's below: its
+ // assets/fonts flow through the same pipeline so grafted /@font-face resolve.
+ for (const da of frameSnap.domAssets) {
+ const t = classifyAsset(da.url, null) ?? (da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
+ recordAsset(da.url, t, null, null, `frame${cand.idx}:${da.via}`);
+ }
+ for (const u of frameSnap.cssUrls) {
+ const t = classifyAsset(u, null) ?? "other";
+ recordAsset(u, t, null, null, "css-url");
+ }
+ for (const ff of frameSnap.fontFaces) {
+ const key = `${ff.family}|${ff.weight ?? ""}|${ff.style ?? ""}|${ff.src}`;
+ if (!fontFaceMap.has(key)) fontFaceMap.set(key, ff);
+ }
+ } else if (vw === canonical && stillBudget > 0) {
+ stillBudget--;
+ // Screenshot fallback — synthetic-poster pattern (see captureVideoStills): the
+ // still's bytes ride the normal asset pipeline under a synthetic URL; the iframe's
+ // inline background then rewrites to the local file at generation.
+ const stillUrl = `https://clone-frame.local/${cand.idx}-${sha1_12(cand.url || String(cand.idx))}.jpg`;
+ const sel = `iframe[data-ditto-frame="${cand.idx}"]`;
+ try {
+ await page.evaluate(forceRevealForShot, sel);
+ const buf = await page.locator(sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
+ recordAsset(stillUrl, "image", "image/jpeg", 200, "iframe-still");
+ storeBytes(stillUrl, "image", buf);
+ frameShotScrolled = true;
+ await page.evaluate(({ s, u }) => {
+ const el = document.querySelector(s) as HTMLElement | null;
+ if (el) {
+ el.style.backgroundImage = `url("${u}")`;
+ el.style.backgroundSize = "100% 100%";
+ el.style.backgroundRepeat = "no-repeat";
+ }
+ }, { s: sel, u: stillUrl });
+ log({ event: "frame_still", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160) });
+ } catch (e) {
+ log({ event: "frame_still_error", viewport: vw, frame: cand.idx, error: String(e).slice(0, 200) });
+ } finally {
+ await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
+ }
+ }
+ }
+ // Element screenshots scroll the target into view; restore scroll 0 before the walk.
+ if (frameShotScrolled) {
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.waitForTimeout(80);
+ }
+
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
@@ -781,6 +972,13 @@ export async function captureSite(opts: {
new Promise((_, rej) => setTimeout(() => rej(new Error(`collectPage timeout vp${vw}`)), 60_000)),
]);
+ // Graft the captured frame subtrees into this viewport's snapshot (offset bboxes,
+ // namespaced ids, frame-URL-absolutized src/href — see graft.ts).
+ for (const g of frameGrafts) {
+ const ok = graftFrameIntoSnapshot(snapshot, g.cand, g.snap);
+ log({ event: ok ? "frame_grafted" : "frame_graft_orphaned", viewport: vw, frame: g.cand.idx, url: g.cand.url.slice(0, 160), nodes: g.snap.doc.nodeCount });
+ }
+
// Merge discovered references from the snapshot (DOM + accessible CSS).
for (const da of snapshot.domAssets) {
const t = classifyAsset(da.url, null) ?? (da.kind === "manifest" ? "manifest" : da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
@@ -895,21 +1093,38 @@ export async function captureSite(opts: {
if (a.storedAs) continue;
if (a.url.startsWith("data:")) continue;
if (!["image", "svg", "video", "font", "lottie", "css", "manifest"].includes(a.type)) continue;
- try {
- const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
- a.status = resp.status();
- if (resp.ok()) {
- const buf = await resp.body();
- a.contentType = a.contentType ?? (resp.headers()["content-type"] || null);
- storeBytes(a.url, a.type, buf);
- if (a.type === "css") parseCssForFonts(buf.toString("utf8"), a.url, fontFaceMap, (u) => {
- const t = classifyAsset(u, null) ?? "other";
- recordAsset(u, t, null, null, "css-text");
- });
- if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
- }
- } catch { /* unreachable/signed — left as skipped */ }
+ // One bounded retry for transiently-failed VISUAL assets (network error / 5xx / 429):
+ // a single flaky fetch otherwise degrades an image to the transparent-GIF placeholder.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let failStatus: number | null = null;
+ try {
+ const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
+ a.status = resp.status();
+ if (resp.ok()) {
+ const buf = await resp.body();
+ a.contentType = a.contentType ?? (resp.headers()["content-type"] || null);
+ storeBytes(a.url, a.type, buf);
+ if (a.type === "css") parseCssForFonts(buf.toString("utf8"), a.url, fontFaceMap, (u) => {
+ const t = classifyAsset(u, null) ?? "other";
+ recordAsset(u, t, null, null, "css-text");
+ });
+ if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
+ break;
+ }
+ failStatus = resp.status();
+ } catch { failStatus = null; /* unreachable/signed — left as skipped */ }
+ if (attempt > 0 || !isRetryableAssetFailure(a.type, failStatus)) break;
+ log({ event: "asset_retry", url: a.url, type: a.type, status: failStatus });
+ await new Promise((r) => setTimeout(r, ASSET_RETRY_DELAY_MS));
+ }
}
+ // Every visual asset that ultimately failed, in one machine-readable event: these are
+ // the boxes that will paint as placeholders (image → transparent GIF, video → blank).
+ const visualMissing = [...assetMap.values()]
+ .filter((a) => !a.storedAs && !a.url.startsWith("data:") && (a.type === "image" || a.type === "svg" || a.type === "video"))
+ .map((a) => ({ url: a.url, type: a.type, status: a.status }))
+ .sort((x, y) => x.url.localeCompare(y.url));
+ if (visualMissing.length) log({ event: "visual_assets_missing", count: visualMissing.length, assets: visualMissing });
const seoResources: SeoResource[] = [];
const fetchedSeo = new Set();
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise => {
diff --git a/compiler/src/capture/graft.ts b/compiler/src/capture/graft.ts
new file mode 100644
index 0000000..d98fded
--- /dev/null
+++ b/compiler/src/capture/graft.ts
@@ -0,0 +1,186 @@
+import type { PageSnapshot, RawNode, RawChild } from "./walker.js";
+
+/**
+ * Cross-origin iframe subtree graft (Stage 2). The in-page walker cannot see into a
+ * cross-origin iframe (Klaviyo/newsletter embeds), so the capture used to record an
+ * empty box and the clone painted a blank frame. Playwright CAN evaluate inside
+ * cross-origin frames from Node, so capture.ts runs the SAME collectPage in each
+ * meaningful frame and this module merges the returned subtree into the page snapshot
+ * as ordinary children of the iframe node:
+ * - bboxes shift by the iframe's content-box origin (frame-doc → page-doc coords);
+ * - id/for/#href are namespaced `f-…` so two frames (or frame + page) can't
+ * collide on DOM ids in the clone;
+ * - src/srcset/href absolutize against the FRAME document's URL (generation resolves
+ * attrs against the main page URL, which would break frame-relative paths);
+ * - the grafted becomes a
, and the iframe node clips (overflow:hidden)
+ * exactly like a real frame viewport.
+ * Downstream the iframe-with-children renders as a positioned
(app.ts), so the
+ * embed's form paints as real, styleable DOM. Frames that can't be grafted fall back
+ * to an element screenshot recorded as the iframe's background (capture.ts).
+ */
+
+/** Determinism/perf caps: at most this many grafted frames per page snapshot… */
+export const MAX_GRAFT_FRAMES = 10;
+/** …and a per-frame walker node budget (a fraction of the main document's 12000). */
+export const FRAME_GRAFT_MAX_NODES = 2000;
+/** Minimum rendered size (both axes) for a frame to carry meaningful content. */
+export const MIN_FRAME_DIM = 48;
+
+export type FramePlan = "skip" | "still" | "graft";
+
+// Ad/analytics/consent plumbing frames render nothing a visitor values — skip entirely.
+const FRAME_SKIP_RE =
+ /(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com)/i;
+// Media players are JS-built canvases/videos whose DOM graft is meaningless; an element
+// screenshot (the poster frame + play chrome) is the faithful static paint.
+const FRAME_STILL_RE =
+ /(?:youtube(?:-nocookie)?\.com\/embed|player\.vimeo\.com|\bwistia\b|fast\.wistia|players?\.brightcove|open\.spotify\.com|w\.soundcloud\.com|google\.com\/maps\/embed)/i;
+
+/** How to materialize a frame's content, from its URL alone (deterministic). */
+export function planForFrameUrl(url: string): FramePlan {
+ const u = (url || "").trim();
+ if (!u || u === "about:blank" || u.startsWith("javascript:")) return "skip";
+ if (FRAME_SKIP_RE.test(u)) return "skip";
+ if (FRAME_STILL_RE.test(u)) return "still";
+ return "graft";
+}
+
+export type FrameCandidate = {
+ idx: number; // stable per-page index, stamped as data-ditto-frame on the element
+ url: string; // the frame element's src (absolute)
+ visible: boolean; // rendered, ≥ MIN_FRAME_DIM on both axes
+ // Content-box origin in page document coordinates: the offset every frame-doc bbox
+ // shifts by (the child browsing context fills the iframe's content box).
+ contentX: number;
+ contentY: number;
+};
+
+/**
+ * Stamp every iframe with a stable `data-ditto-frame` index (DOM order; idempotent so
+ * later viewports reuse the same identity) and report each frame's geometry/visibility.
+ * Serialized into the page via page.evaluate — must stay self-contained.
+ */
+export function enumerateFramesInPage(): FrameCandidate[] {
+ const frames = Array.from(document.querySelectorAll("iframe"));
+ let next = 0;
+ for (const f of frames) {
+ const cur = f.getAttribute("data-ditto-frame");
+ if (cur !== null) next = Math.max(next, parseInt(cur, 10) + 1);
+ }
+ const sx = window.scrollX, sy = window.scrollY;
+ const out: FrameCandidate[] = [];
+ for (const f of frames) {
+ let idxAttr = f.getAttribute("data-ditto-frame");
+ if (idxAttr === null) { idxAttr = String(next++); f.setAttribute("data-ditto-frame", idxAttr); }
+ const cs = getComputedStyle(f);
+ const r = f.getBoundingClientRect();
+ const visible = cs.display !== "none" && cs.visibility !== "hidden" &&
+ parseFloat(cs.opacity || "1") > 0 && r.width >= 48 && r.height >= 48;
+ out.push({
+ idx: parseInt(idxAttr, 10),
+ url: (f as HTMLIFrameElement).src || "",
+ visible,
+ contentX: Math.round((r.x + sx + parseFloat(cs.borderLeftWidth || "0") + parseFloat(cs.paddingLeft || "0")) * 100) / 100,
+ contentY: Math.round((r.y + sy + parseFloat(cs.borderTopWidth || "0") + parseFloat(cs.paddingTop || "0")) * 100) / 100,
+ });
+ }
+ return out.sort((a, b) => a.idx - b.idx);
+}
+
+function isTextRaw(c: RawChild): c is { text: string } {
+ return (c as { text?: string }).text !== undefined;
+}
+
+/** Does the frame's captured tree paint anything (a visible element or real text)? */
+export function frameHasRenderableContent(root: RawNode | undefined): boolean {
+ if (!root) return false;
+ const visit = (n: RawNode): boolean => {
+ for (const c of n.children) {
+ if (isTextRaw(c)) { if (c.text.trim().length > 0) return true; continue; }
+ if (c.visible || c.rawHTML) return true;
+ if (visit(c)) return true;
+ }
+ return false;
+ };
+ return visit(root);
+}
+
+/** Find the iframe RawNode stamped with the given data-ditto-frame index. */
+export function findFrameNode(root: RawNode, idx: number): RawNode | null {
+ if (root.tag === "iframe" && root.attrs?.["data-ditto-frame"] === String(idx)) return root;
+ for (const c of root.children) {
+ if (isTextRaw(c)) continue;
+ const hit = findFrameNode(c, idx);
+ if (hit) return hit;
+ }
+ return null;
+}
+
+const ABS_URL_ATTRS = ["src", "poster", "data-lazy-src", "data-src", "data-original", "data-ll-src"];
+const ABS_SRCSET_ATTRS = ["srcset", "data-lazy-srcset", "data-srcset"];
+
+/**
+ * Merge one frame snapshot into the page snapshot as children of its iframe node.
+ * Returns true when the graft landed. Mutates `snapshot` (offsets, namespacing and the
+ * iframe's clipping are applied to the frame subtree copy embedded in it).
+ */
+export function graftFrameIntoSnapshot(
+ snapshot: PageSnapshot,
+ frame: { idx: number; contentX: number; contentY: number },
+ frameSnap: PageSnapshot,
+): boolean {
+ const host = findFrameNode(snapshot.root, frame.idx);
+ if (!host || !frameSnap?.root) return false;
+ if (!frameHasRenderableContent(frameSnap.root)) return false;
+
+ const prefix = `f${frame.idx}-`;
+ const frameUrl = frameSnap.doc?.url || "";
+ const abs = (u: string): string => {
+ try { return new URL(u, frameUrl).href; } catch { return u; }
+ };
+ const absSrcset = (v: string): string =>
+ v.split(",").map((part) => {
+ const bits = part.trim().split(/\s+/);
+ if (bits[0] && !bits[0].startsWith("data:")) bits[0] = abs(bits[0]);
+ return bits.join(" ");
+ }).join(", ");
+ const nsIdList = (v: string): string =>
+ v.split(/\s+/).filter(Boolean).map((id) => prefix + id).join(" ");
+
+ const visit = (n: RawNode): void => {
+ n.bbox = {
+ ...n.bbox,
+ x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
+ y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
+ };
+ const a = n.attrs ?? {};
+ delete a["data-cid-cap"]; // capture-ids belong to the main document only
+ if (a.id) a.id = prefix + a.id;
+ if (a.for) a.for = prefix + a.for;
+ for (const k of ["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-activedescendant"]) {
+ if (a[k]) a[k] = nsIdList(a[k]!);
+ }
+ if (a.href) a.href = a.href.startsWith("#") ? "#" + prefix + a.href.slice(1) : abs(a.href);
+ for (const k of ABS_URL_ATTRS) if (a[k] && !a[k]!.startsWith("data:")) a[k] = abs(a[k]!);
+ for (const k of ABS_SRCSET_ATTRS) if (a[k]) a[k] = absSrcset(a[k]!);
+ for (const c of n.children) if (!isTextRaw(c)) visit(c);
+ };
+
+ const wrapper = frameSnap.root; // the frame's
+ visit(wrapper);
+ wrapper.tag = "div"; // a nested is not valid; the box/styles replay identically
+
+ host.children = [wrapper];
+ // A real frame viewport clips its document; the replacement
must too, at every
+ // captured viewport (each per-viewport snapshot is grafted independently).
+ host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
+ // An iframe is a REPLACED element: `display:inline` (the default) still honors its
+ // width/height. The
that replaces it at generation is not — inline would collapse
+ // the box — so translate to the behavior-equivalent inline-block.
+ if ((host.computed.display || "inline") === "inline") host.computed.display = "inline-block";
+ // Diagnostics only (nodeCount is logged, never gated).
+ snapshot.doc.nodeCount += frameSnap.doc?.nodeCount ?? 0;
+ // @keyframes referenced by grafted nodes must exist in the page snapshot's set.
+ if (frameSnap.keyframes?.length) snapshot.keyframes.push(...frameSnap.keyframes);
+ return true;
+}
diff --git a/compiler/src/capture/motion.ts b/compiler/src/capture/motion.ts
index 7019bd8..1dcc955 100644
--- a/compiler/src/capture/motion.ts
+++ b/compiler/src/capture/motion.ts
@@ -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 {
try {
await page.evaluate(() => {
- const out: Record = {};
+ const out: Record = {};
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 }).__cloneReveal || {};
+ const probed = (window as unknown as { __cloneReveal?: Record }).__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 */ }
diff --git a/compiler/src/capture/stabilize.ts b/compiler/src/capture/stabilize.ts
new file mode 100644
index 0000000..e500549
--- /dev/null
+++ b/compiler/src/capture/stabilize.ts
@@ -0,0 +1,411 @@
+import type { Page } from "playwright";
+
+/**
+ * Pre-snapshot stabilization (Stage 2). Two dynamic behaviors otherwise make the
+ * per-viewport snapshots disagree with each other and with what a settled visitor sees:
+ *
+ * - **Lazy-loader placeholders** (WP Rocket / lazysizes): the real URL lives in a data
+ * attribute while `src` holds a 0-size placeholder; `autoScroll` outruns their
+ * IntersectionObserver, so snapshots record collapsed sections — and the interaction
+ * pass can trigger the swap midway, leaving viewports INCONSISTENT (cropin's "Global
+ * presence" map: 0×0 at 375/768/1280, 898px at 1920). Promoting the data attrs to
+ * real ones ONCE, before any snapshot, makes every viewport measure the stable
+ * post-reveal size (validated against the live site).
+ * - **Autoplaying carousels** (Splide/Swiper-style transform tracks): each viewport
+ * snapshot freezes a DIFFERENT translateX offset which the generator bakes into
+ * per-band CSS (ooni's splide02: -375/0/-1280/-1920 across the four widths). Settling
+ * — autoplay paused, track at the home slide — before EVERY snapshot makes all
+ * viewports (including the post-interaction 1920 pass) see one canonical state.
+ *
+ * Scope guard for motion capture (motion.ts contract): carousel settling touches ONLY
+ * elements matching the named-library selectors below, and pauses only the track's own
+ * WAAPI/CSS animations. rAF-driven marquees (Framer Motion tickers) match none of these
+ * selectors and keep running, so `detectMarquees` still observes their velocity; paused
+ * WAAPI animations remain in `document.getAnimations()` with keyframes/timing intact.
+ */
+
+// ---- Lazy-media promotion (runs in the page) ----
+
+/**
+ * Promote lazy-loader data attributes to real ones and wait (bounded) for the newly-real
+ * images to decode, so bboxes are measured loaded. Only values that look like URLs are
+ * promoted (some themes stash JSON/flags in data-src-like attrs); an `src` already equal
+ * to the target is left alone, so the pass is idempotent. Returns the number of elements
+ * changed. Serialized into the page via page.evaluate — must stay self-contained.
+ */
+export async function promoteLazyMediaInPage(): Promise {
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ const urlish = (v: string | null | undefined): v is string => {
+ if (!v) return false;
+ const s = v.trim();
+ if (!s || s.length > 4096 || /[<>"'\s]/.test(s)) return false;
+ return /^(?:https?:)?\/\//i.test(s) || s.startsWith("/") || s.startsWith("./") || s.startsWith("../") ||
+ /^data:image\//i.test(s) || /^[\w][^:]*\.[a-z0-9]{2,5}(?:[?#]|$)/i.test(s);
+ };
+ const srcsetish = (v: string | null | undefined): v is string => {
+ if (!v) return false;
+ const first = v.split(",")[0]?.trim().split(/\s+/)[0];
+ return urlish(first);
+ };
+ const setAttr = (el: Element, name: string, value: string): boolean => {
+ if (el.getAttribute(name) === value) return false;
+ el.setAttribute(name, value);
+ return true;
+ };
+ const promotedImgs: HTMLImageElement[] = [];
+ let count = 0;
+ const SEL =
+ "img[data-lazy-src],img[data-src],img[data-lazy-srcset],img[data-srcset],img[data-lazy-sizes],img[data-sizes]," +
+ "source[data-lazy-srcset],source[data-srcset],iframe[data-lazy-src],iframe[data-src],[data-bg]";
+ for (const el of Array.from(document.querySelectorAll(SEL))) {
+ let changed = false;
+ const tag = el.tagName;
+ if (tag === "IMG" || tag === "IFRAME") {
+ const src = el.getAttribute("data-lazy-src") ?? el.getAttribute("data-src");
+ if (urlish(src)) changed = setAttr(el, "src", src.trim()) || changed;
+ }
+ if (tag === "IMG" || tag === "SOURCE") {
+ const srcset = el.getAttribute("data-lazy-srcset") ?? el.getAttribute("data-srcset");
+ if (srcsetish(srcset)) changed = setAttr(el, "srcset", srcset.trim()) || changed;
+ // lazysizes' `data-sizes="auto"` is a computed-at-swap flag, not a real sizes value.
+ const sizes = (el.getAttribute("data-lazy-sizes") ?? el.getAttribute("data-sizes"))?.trim();
+ if (sizes && sizes !== "auto") changed = setAttr(el, "sizes", sizes) || changed;
+ }
+ const bg = el.getAttribute("data-bg");
+ if (bg) {
+ // data-bg carries either a raw URL (lazysizes) or a full url(...) (WP Rocket).
+ const inner = bg.trim().replace(/^url\(\s*(['"]?)(.*?)\1\s*\)$/i, "$2").trim();
+ if (urlish(inner)) {
+ const want = `url("${inner}")`;
+ const st = (el as HTMLElement).style;
+ if (st.backgroundImage !== want) { st.backgroundImage = want; changed = true; }
+ }
+ }
+ if (changed) {
+ count++;
+ if (tag === "IMG") { el.setAttribute("loading", "eager"); promotedImgs.push(el as HTMLImageElement); }
+ }
+ }
+ if (promotedImgs.length) {
+ await Promise.race([
+ Promise.all(promotedImgs.map((img) => (typeof img.decode === "function" ? img.decode() : Promise.resolve()).catch(() => { /* broken URL — bbox stays as-is */ }))),
+ sleep(4000),
+ ]);
+ }
+ return count;
+}
+
+/** Node-side wrapper: bounded + never fatal (a hung page just skips promotion). */
+export async function promoteLazyMedia(page: Page): Promise {
+ try {
+ return await Promise.race([
+ page.evaluate(promoteLazyMediaInPage),
+ new Promise((res) => setTimeout(() => res(0), 8000)),
+ ]);
+ } catch {
+ return 0;
+ }
+}
+
+// ---- Scroll-reveal settling (runs in the page) ----
+
+/** Fixed dwell per scroll step. Reveal libraries fire from IntersectionObserver callbacks
+ * or throttled scroll handlers (WOW ~100ms, AOS 99ms debounce); the plain autoScroll's
+ * 60ms cadence outruns them, leaving reveal wrappers baked `visibility:hidden` in the
+ * snapshot. 400ms per step reliably clears every observed library. Deterministic constant. */
+export const REVEAL_DWELL_MS = 400;
+/** Bound the dwell walk for pathological/endless pages (~80 × 0.75 viewport ≈ 60 screens). */
+export const REVEAL_MAX_STEPS = 80;
+/** Bounded wait for FINITE entrance animations started by the walk to finish, so no
+ * viewport snapshot freezes a mid-fade frame (the ~5%-opacity ghost state). */
+export const REVEAL_ANIMATION_WAIT_MS = 3000;
+
+export type RevealSettleResult = { steps: number; animationsAwaited: number };
+
+/**
+ * Deterministic dwell-scroll so every one-shot scroll reveal (Elementor waypoints,
+ * WOW/AOS class swaps, IntersectionObserver entrances) fires BEFORE any viewport
+ * snapshot — the same class of one-shot load-state as lazy media, settled the same way
+ * (once, before the viewport loop). Steps 0.75×viewport with a fixed dwell through the
+ * full scrollHeight, waits for the entrance animations it started to complete (infinite
+ * iterations excluded — they never finish by design), then restores scroll 0.
+ * Serialized into the page via page.evaluate — must stay self-contained.
+ */
+export async function settleScrollRevealsInPage(cfg: { dwellMs: number; maxSteps: number; animWaitMs: number }): Promise {
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ const step = Math.max(Math.round(window.innerHeight * 0.75), 200);
+ const maxScroll = () => document.documentElement.scrollHeight - window.innerHeight;
+ let y = 0;
+ let steps = 0;
+ while (y < maxScroll() && steps < cfg.maxSteps) {
+ y += step;
+ window.scrollTo(0, y);
+ await sleep(cfg.dwellMs);
+ steps++;
+ }
+ let animationsAwaited = 0;
+ try {
+ const anims = (document.getAnimations?.() ?? []).filter((a) => {
+ try {
+ if (a.playState !== "running") return false;
+ const t = (a.effect as KeyframeEffect | null)?.getTiming();
+ return t != null && t.iterations !== Infinity;
+ } catch { return false; }
+ });
+ animationsAwaited = anims.length;
+ await Promise.race([
+ Promise.allSettled(anims.map((a) => a.finished)),
+ sleep(cfg.animWaitMs),
+ ]);
+ } catch { /* getAnimations unsupported — the fixed post-wait still applies */ }
+ window.scrollTo(0, 0);
+ await sleep(250); // fixed post-wait: let scroll-position-dependent styles re-settle at top
+ return { steps, animationsAwaited };
+}
+
+/** Node-side wrapper: bounded + never fatal (a hung page just skips settling). */
+export async function settleScrollReveals(page: Page): Promise {
+ const empty: RevealSettleResult = { steps: 0, animationsAwaited: 0 };
+ const cfg = { dwellMs: REVEAL_DWELL_MS, maxSteps: REVEAL_MAX_STEPS, animWaitMs: REVEAL_ANIMATION_WAIT_MS };
+ const bound = cfg.maxSteps * cfg.dwellMs + cfg.animWaitMs + 8000;
+ try {
+ return await Promise.race([
+ page.evaluate(settleScrollRevealsInPage, cfg),
+ new Promise((res) => setTimeout(() => res(empty), bound)),
+ ]);
+ } catch {
+ return empty;
+ }
+}
+
+/**
+ * Defensive follow-up to the dwell walk: elements STILL carrying a known pre-reveal
+ * marker (far below fold past the step bound, or keyed to a non-scroll trigger) are
+ * moved to the library's OWN revealed state — the classes the library would add/remove
+ * — never raw style overrides, so the captured computed styles are the library's
+ * genuine post-reveal values. Known-library allowlist only (same philosophy as the
+ * carousel selectors below); elements hidden for real reasons are untouched.
+ * Serialized into the page via page.evaluate — must stay self-contained.
+ */
+export function neutralizePreRevealInPage(): number {
+ const stillHidden = (el: Element): boolean => {
+ try {
+ const cs = getComputedStyle(el);
+ return cs.visibility === "hidden" || parseFloat(cs.opacity || "1") <= 0.05;
+ } catch { return false; }
+ };
+ let n = 0;
+ // Elementor waypoint reveals: `.elementor-invisible` is removed and `animated` + the
+ // entrance-animation class (data-settings._animation / .animation) added on reveal.
+ const elementorReveal = (el: Element): void => {
+ let anim = "";
+ try {
+ const s = JSON.parse(el.getAttribute("data-settings") || "{}") as Record;
+ const v = s["_animation"] ?? s["animation"];
+ if (typeof v === "string") anim = v.trim();
+ } catch { /* malformed settings — reveal without the animation class */ }
+ el.classList.remove("elementor-invisible");
+ el.classList.add("animated");
+ if (anim && anim !== "none") el.classList.add(anim);
+ };
+ for (const el of Array.from(document.querySelectorAll(".elementor-invisible"))) { elementorReveal(el); n++; }
+ // Elementor variants where the invisible class was renamed but the entrance setting
+ // remains: still-hidden elements whose data-settings configure an animation.
+ for (const el of Array.from(document.querySelectorAll('[data-settings*="animation"]'))) {
+ if (!stillHidden(el)) continue;
+ elementorReveal(el);
+ n++;
+ }
+ // WOW.js: init() sets inline visibility:hidden; reveal sets it visible + adds `animated`
+ // (the keyframe class, e.g. fadeInUp, is already in the element's class list).
+ for (const el of Array.from(document.querySelectorAll(".wow:not(.animated)")) as HTMLElement[]) {
+ el.classList.add("animated");
+ el.style.visibility = "visible";
+ n++;
+ }
+ // AOS: [data-aos] elements are hidden by attribute selectors until `.aos-animate` lands.
+ for (const el of Array.from(document.querySelectorAll("[data-aos]:not(.aos-animate)"))) {
+ el.classList.add("aos-animate");
+ n++;
+ }
+ return n;
+}
+
+/** Node-side wrapper: bounded + never fatal. */
+export async function neutralizePreReveal(page: Page): Promise {
+ try {
+ return await Promise.race([
+ page.evaluate(neutralizePreRevealInPage),
+ new Promise((res) => setTimeout(() => res(0), 8000)),
+ ]);
+ } catch {
+ return 0;
+ }
+}
+
+// ---- Carousel settling (runs in the page) ----
+
+export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number };
+
+/**
+ * Deterministically settle recognizable library carousels: engage the library's own
+ * pause path, neutralize any animation driving the track, and navigate to the REAL first
+ * slide (home). Navigation preference:
+ * 1. the exposed Swiper instance (`el.swiper`) — stop autoplay + slideTo(Loop)(0, 0);
+ * 2. the first pagination bullet (libraries resolve loop-mode clones themselves);
+ * 3. prev-arrow clicks back from the marked active slide's real index;
+ * 4. inline `translateX(0)` — only for a non-loop track with no controls (loop mode
+ * prepends clones, so 0 is not home there; such a track is left paused as-is).
+ * Autoplay pause is best-effort-deterministic: Swiper via its instance; Splide/Slick/Glide
+ * pause on pointer-enter by default, so a synthetic mouseenter/mouseover latches them
+ * (nothing dispatches the matching mouseleave). Ends with a bounded wait for every track
+ * transform to stop changing so the caller snapshots a settled frame.
+ */
+export async function settleCarouselsInPage(): Promise {
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ const ROOT_SEL = ".splide, .swiper, .swiper-container, .slick-slider, .glide";
+ const TRACK_SEL = ".splide__list, .swiper-wrapper, .slick-track, .glide__slides";
+ const BULLET_SEL = ".splide__pagination__bullet, .swiper-pagination-bullet, .slick-dots button, .glide__bullet";
+ const PREV_SEL = ".splide__arrow--prev, .swiper-button-prev, .slick-prev, [data-glide-dir='<']";
+ const ACTIVE_SEL = ".is-active, .swiper-slide-active, .slick-current, .glide__slide--active";
+ const CLONE_SEL = ".splide__slide--clone, .swiper-slide-duplicate, .slick-cloned, .glide__slide--clone";
+ const txOf = (el: Element): number => {
+ try { return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41; } catch { return 0; }
+ };
+
+ const roots = Array.from(document.querySelectorAll(ROOT_SEL));
+ const tracks: Element[] = [];
+ let normalized = 0;
+ let neutralizedAnims = 0;
+ for (const root of roots) {
+ const track = Array.from(root.querySelectorAll(TRACK_SEL)).find((t) => t.closest(ROOT_SEL) === root);
+ if (!track) continue;
+ tracks.push(track);
+ // pause-on-hover latch (Splide/Slick default-on; Glide's hoverpause): synthetic
+ // pointer-enter with no matching leave keeps autoplay paused for the snapshot.
+ for (const t of [root, track.parentElement, track]) {
+ if (!t) continue;
+ try {
+ t.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
+ t.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ } catch { /* ignore */ }
+ }
+ // Neutralize the track's OWN animations only (scoped so motion.ts's marquee/rotator
+ // capture is untouched). CSS transitions/animations are CANCELED, not paused: pausing
+ // a CSSTransition disassociates it from style and it then HOLDS its frozen mid-flight
+ // transform, overriding the home navigation below (the declarative path reconstructs
+ // CSS motion from the IR, so canceling loses nothing). Pure WAAPI is PAUSED so it
+ // stays in getAnimations() with keyframes/timing intact for motion.ts to record.
+ try {
+ for (const a of (track as HTMLElement).getAnimations?.() ?? []) {
+ try {
+ const ctor = a.constructor.name;
+ if (ctor === "CSSTransition" || ctor === "CSSAnimation") a.cancel(); else a.pause();
+ neutralizedAnims++;
+ } catch { /* ignore */ }
+ }
+ } catch { /* ignore */ }
+
+ let home = false;
+ // 1) Swiper exposes its instance on the container.
+ const sw = (root as { swiper?: { autoplay?: { stop?: () => void }; slideTo?: (i: number, ms?: number) => void; slideToLoop?: (i: number, ms?: number) => void } }).swiper;
+ if (sw) {
+ try { sw.autoplay?.stop?.(); } catch { /* ignore */ }
+ try {
+ if (sw.slideToLoop) { sw.slideToLoop(0, 0); home = true; }
+ else if (sw.slideTo) { sw.slideTo(0, 0); home = true; }
+ } catch { /* ignore */ }
+ }
+ // 2) First pagination bullet (clicked even if hidden at this width — a destroyed
+ // breakpoint variant just ignores the click).
+ if (!home) {
+ const bullet = Array.from(root.querySelectorAll(BULLET_SEL)).find((b) => b.closest(ROOT_SEL) === root) as HTMLElement | undefined;
+ if (bullet) { try { bullet.click(); home = true; } catch { /* ignore */ } }
+ }
+ // 3) Step back from the marked active slide's real index with the prev arrow.
+ if (!home) {
+ const prev = Array.from(root.querySelectorAll(PREV_SEL)).find((b) => b.closest(ROOT_SEL) === root) as HTMLElement | undefined;
+ const active = Array.from(track.children).find((s) => s.matches(ACTIVE_SEL));
+ if (prev && active) {
+ const real = Array.from(track.children).filter((s) => !s.matches(CLONE_SEL));
+ const idx = real.indexOf(active);
+ if (idx >= 0) {
+ for (let k = 0; k < Math.min(idx, 30); k++) { try { prev.click(); } catch { break; } await sleep(90); }
+ home = true;
+ }
+ }
+ }
+ // 4) No controls: pin translateX(0) — home for a non-loop track. Loop mode prepends
+ // clones (0 shows a clone), so a control-less loop track is left paused as-is.
+ if (!home) {
+ const isLoop = /--loop\b/.test(root.className) || track.querySelector(CLONE_SEL) != null;
+ if (!isLoop && Math.abs(txOf(track)) > 0.5) {
+ (track as HTMLElement).style.transform = "translateX(0px)";
+ home = true;
+ }
+ }
+ if (home) normalized++;
+ }
+
+ // Bounded wait for the navigation transitions to land (and confirm nothing is still
+ // auto-advancing): every track transform stable for 3 consecutive samples.
+ if (tracks.length) {
+ let prevSig = tracks.map((t) => Math.round(txOf(t))).join(",");
+ let stable = 0;
+ for (let i = 0; i < 14 && stable < 3; i++) {
+ await sleep(140);
+ const sig = tracks.map((t) => Math.round(txOf(t))).join(",");
+ if (sig === prevSig) stable++; else { stable = 0; prevSig = sig; }
+ }
+ }
+ return { roots: tracks.length, normalized, neutralizedAnims };
+}
+
+/** Node-side wrapper: bounded + never fatal. */
+export async function settleCarousels(page: Page): Promise {
+ const empty: CarouselSettleResult = { roots: 0, normalized: 0, neutralizedAnims: 0 };
+ try {
+ return await Promise.race([
+ page.evaluate(settleCarouselsInPage),
+ new Promise((res) => setTimeout(() => res(empty), 10_000)),
+ ]);
+ } catch {
+ return empty;
+ }
+}
+
+// ---- Force-reveal for element screenshots (runs in the page) ----
+
+/**
+ * A visibility:hidden video (entrance animation not yet fired) passes the size gate but
+ * `locator.screenshot` auto-waits for visibility and times out. Force the element and any
+ * hidden ancestor visible for the shot, recording each prior inline value so
+ * `restoreRevealForShot` puts everything back exactly. Returns how many were forced.
+ */
+export function forceRevealForShot(sel: string): number {
+ const el = document.querySelector(sel) as HTMLElement | null;
+ if (!el) return 0;
+ let n = 0;
+ for (let cur: HTMLElement | null = el; cur; cur = cur.parentElement) {
+ if (getComputedStyle(cur).visibility !== "hidden") continue;
+ const prev = cur.style.getPropertyValue("visibility");
+ const prio = cur.style.getPropertyPriority("visibility");
+ cur.setAttribute("data-clone-vis-restore", `${prio}|${prev}`);
+ cur.style.setProperty("visibility", "visible", "important");
+ n++;
+ }
+ return n;
+}
+
+/** Undo forceRevealForShot exactly (inline value + priority, or absence). */
+export function restoreRevealForShot(): void {
+ for (const el of Array.from(document.querySelectorAll("[data-clone-vis-restore]")) as HTMLElement[]) {
+ const raw = el.getAttribute("data-clone-vis-restore") ?? "|";
+ el.removeAttribute("data-clone-vis-restore");
+ const i = raw.indexOf("|");
+ const prio = raw.slice(0, i);
+ const prev = raw.slice(i + 1);
+ if (prev) el.style.setProperty("visibility", prev, prio);
+ else el.style.removeProperty("visibility");
+ }
+}
diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts
index 0959e14..0a36096 100644
--- a/compiler/src/capture/walker.ts
+++ b/compiler/src/capture/walker.ts
@@ -47,6 +47,9 @@ export type RawNode = {
sizing?: RawSizing;
before?: RawStyle;
after?: RawStyle;
+ // ::placeholder computed style for input/textarea with placeholder text. Without it the
+ // clone renders the browser's default gray, losing the authored placeholder color/type.
+ placeholder?: RawStyle;
rawHTML?: string; // set for inline