diff --git a/compiler/fixtures/carousel-autoplay.html b/compiler/fixtures/carousel-autoplay.html new file mode 100644 index 0000000..1288c91 --- /dev/null +++ b/compiler/fixtures/carousel-autoplay.html @@ -0,0 +1,83 @@ + + + + + + Autoplay carousel fixture + + + +
+

Autoplay carousel

+ +
+
+
    +
  • Slide 1
  • +
  • Slide 2
  • +
  • Slide 3
  • +
  • Slide 4
  • +
+
+ +
+ +
+
+
A
+
B
+
C
+
+
+
+ + + + diff --git a/compiler/fixtures/iframe-embed.html b/compiler/fixtures/iframe-embed.html new file mode 100644 index 0000000..9922abe --- /dev/null +++ b/compiler/fixtures/iframe-embed.html @@ -0,0 +1,25 @@ + + + + +Newsletter embed + + + +
+ + + + Terms + +
+ + diff --git a/compiler/fixtures/iframe-host.html b/compiler/fixtures/iframe-host.html new file mode 100644 index 0000000..e661b2c --- /dev/null +++ b/compiler/fixtures/iframe-host.html @@ -0,0 +1,23 @@ + + + + +Iframe host + + + +

Host page

+ + + + diff --git a/compiler/fixtures/lazy.html b/compiler/fixtures/lazy.html new file mode 100644 index 0000000..733f4dd --- /dev/null +++ b/compiler/fixtures/lazy.html @@ -0,0 +1,37 @@ + + + + + + Lazy-media fixture + + + +

Lazy media

+ + + hero + + + icon + + + + picture + + + +
+ + + template token + + + already swapped + + 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 children: RawChild[]; }; @@ -100,7 +103,9 @@ export type PageSnapshot = { keyframes: string[]; // raw @keyframes blocks from accessible sheets }; -export function collectPage(): PageSnapshot { +// `| void` keeps the no-arg `page.evaluate(collectPage)` call sites type-compatible +// (Playwright types the missing argument as void); frame grafts pass { maxNodes }. +export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { // NOTE: This function is serialized and run in the browser via page.evaluate, // so every constant/helper it uses must be declared INSIDE it (no module-scope // closure is available in the page). @@ -167,11 +172,25 @@ export function collectPage(): PageSnapshot { "overflow", "objectFit", ]; + // ::placeholder property set: the visual identity of placeholder text. Kept small — + // it inherits everything else from the input itself. + const PLACEHOLDER_PROPS: string[] = [ + "color", "opacity", "fontFamily", "fontSize", "fontWeight", "fontStyle", + "letterSpacing", "textTransform", + ]; + const SKIP_TAGS = new Set([ "script", "style", "link", "meta", "noscript", "template", "base", "title", "head", ]); - const MAX_NODES = 12000; + // Text-level tags whose whitespace-only text still renders even as the FIRST/ONLY + // child (the lone-space case below); a block container's stray whitespace does not. + const INLINE_TEXT_TAGS = new Set([ + "span", "strong", "em", "b", "i", "a", "u", "small", "sub", "sup", "code", "abbr", "time", "label", + ]); + + // Frame grafts pass a lower cap so one pathological embed can't dominate the snapshot. + const MAX_NODES = (opts && opts.maxNodes) || 12000; const round2 = (n: number): number => Math.round((n || 0) * 100) / 100; const scrollX = window.scrollX; @@ -358,6 +377,13 @@ export function collectPage(): PageSnapshot { } } catch { /* ignore */ } + // ::placeholder: only meaningful on a control that shows placeholder text. + if ((tag === "input" || tag === "textarea") && (attrs.placeholder || "").trim()) { + try { + node.placeholder = grabStyle(window.getComputedStyle(el, "::placeholder"), PLACEHOLDER_PROPS); + } catch { /* ignore */ } + } + // Inline SVG → raw markup, no recursion. if (tag === "svg") { node.rawHTML = el.outerHTML; @@ -379,6 +405,11 @@ export function collectPage(): PageSnapshot { } else if (t.length > 0 && node.children.length > 0) { // Preserve a single significant space between inline elements. node.children.push({ text: " " }); + } else if (t.length > 0 && (/^inline/.test(cs.display) || INLINE_TEXT_TAGS.has(tag))) { + // Whitespace that is the FIRST/ONLY child of an inline element still renders + // (`of the` keeps its space); dropping it fuses the adjacent + // text runs. Scoped to inline parents so block containers stay empty. + node.children.push({ text: " " }); } continue; } diff --git a/compiler/src/cli.ts b/compiler/src/cli.ts index c3ca9f4..ceb5d58 100755 --- a/compiler/src/cli.ts +++ b/compiler/src/cli.ts @@ -38,6 +38,9 @@ export type CloneResult = { /** A stable, timestamp-free path to the app (via the `runs//latest` symlink), * present in runs-layout mode when the symlink could be created. */ stableAppDir?: string; + /** Visual assets (image/svg/video) that could not be downloaded — those boxes render + * as placeholders. Surfaced in the CLI summary; details in generated/assets.json. */ + visualAssetsMissing?: number; }; export function siteIdFromUrl(url: string): string { @@ -455,7 +458,8 @@ export async function runClone(opts: CloneOptions): Promise { const gen = generateAll({ sourceDir, capture, viewports, sampleViewports: captureViewports, url: opts.url, outDir: generatedDir }); logBoth({ event: "ir_built", nodes: gen.ir.doc.nodeCount }); logBoth({ event: "inferred", sections: gen.sections.length, assets: gen.assetGraph.entries.length, fonts: gen.fontGraph.entries.length }); - logBoth({ event: "generated", assetsCopied: gen.assetsCopied, assetsMissing: gen.assetsMissing.length }); + const visualAssetsMissing = gen.assetGraph.entries.filter((e) => e.impact === "visual_missing").length; + logBoth({ event: "generated", assetsCopied: gen.assetsCopied, assetsMissing: gen.assetsMissing.length, visualAssetsMissing }); // When the source capture lacks native probe flags (this sandbox can't reach the // live site through the egress proxy), optionally iterate render→regen so the LOCAL clone-probe @@ -479,7 +483,7 @@ export async function runClone(opts: CloneOptions): Promise { stableAppDir = writeLatestPointer(runsDir, siteId, runDir); } - return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url, stableAppDir }; + return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url, stableAppDir, visualAssetsMissing }; } /** Record the newest run for a site in the runs layout: a `latest.json` breadcrumb (used by @@ -576,11 +580,11 @@ async function main(): Promise { // --serve installs deps + starts the dev server after cloning; --open also launches the browser. const open = hasAnyFlag(args, ["--open"]); const serve = open || hasAnyFlag(args, ["--serve"]); - const finish = async (res: { appDir: string; stableAppDir?: string }) => { + const finish = async (res: { appDir: string; stableAppDir?: string; visualAssetsMissing?: number }) => { if (serve) { await serveApp(res.appDir, { open }); } else { - process.stderr.write(doneSummary({ url, appDir: res.appDir, framework, stableAppDir: res.stableAppDir })); + process.stderr.write(doneSummary({ url, appDir: res.appDir, framework, stableAppDir: res.stableAppDir, visualAssetsMissing: res.visualAssetsMissing })); } }; const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]); diff --git a/compiler/src/cliSummary.ts b/compiler/src/cliSummary.ts index fa56c3e..5d6c420 100644 --- a/compiler/src/cliSummary.ts +++ b/compiler/src/cliSummary.ts @@ -20,6 +20,9 @@ export type DoneSummaryInput = { framework: "next" | "vite"; /** Stable path (a `runs//latest` symlink target) preferred as the `cd` target when present. */ stableAppDir?: string; + /** Visual assets (image/svg/video) that failed to download — those boxes paint as + * placeholders in the clone. 0/undefined → line omitted. */ + visualAssetsMissing?: number; }; /** Double-quote a path for a POSIX shell so spaces and wrapping don't break copy-paste. */ @@ -46,6 +49,12 @@ export function doneSummary(input: DoneSummaryInput): string { " Or re-run with --serve to install deps and start the dev server for you", " (add --open to launch the browser too).", ]; + if (input.visualAssetsMissing) { + const n = input.visualAssetsMissing; + lines.push(""); + lines.push(` ⚠ ${n} visual asset${n === 1 ? "" : "s"} could not be downloaded and will render as`); + lines.push(` placeholders — see generated/assets.json (classification "skipped").`); + } if (input.stableAppDir && input.stableAppDir !== input.appDir) { lines.push(""); lines.push(` The path above is a stable pointer to the newest clone. This exact run:`); diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 4592e11..456ad77 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -195,6 +195,35 @@ function resolveUrl(url: string, base: string): string { try { return new URL(url, base).href; } catch { return url; } } +/** Srcset candidates rewritten to materialized local assets, order preserved; + * candidates whose URL did not materialize are dropped (never placeholders — + * srcset wins over src, so a placeholder would beat the rewritten fallback). */ +function keptSrcsetCandidates(value: string, assetMap: Map, sourceUrl: string): string[] { + return value.split(",").map((p) => p.trim()).filter(Boolean).map((seg) => { + const sp = seg.split(/\s+/); + const abs = resolveUrl(sp[0] ?? "", sourceUrl); + const local = assetMap.get(abs); + return local ? [local, ...sp.slice(1)].join(" ") : null; + }).filter((x): x is string => x !== null); +} + +/** True when a