Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes
Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
(collapsed sections, viewport-inconsistent captures); settle autoplay
carousels to home slide before every snapshot; force-reveal hidden
videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
picture>source through IR prune with srcset rewrite (mobile-crop-on-
desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
forms) with element-screenshot fallback; asset download retry +
visual-assets-missing reporting
Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
da537e68b8
commit
9aed2540aa
+236
-21
@@ -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<void> {
|
||||
// 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<FrameCandidate[]>((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<never>((_, 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 <img>/@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<never>((_, 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<string>();
|
||||
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise<void> => {
|
||||
|
||||
@@ -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<idx>-…` 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 <body> becomes a <div>, and the iframe node clips (overflow:hidden)
|
||||
* exactly like a real frame viewport.
|
||||
* Downstream the iframe-with-children renders as a positioned <div> (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 <body>
|
||||
visit(wrapper);
|
||||
wrapper.tag = "div"; // a nested <body> is not valid; the box/styles replay identically
|
||||
|
||||
host.children = [wrapper];
|
||||
// A real frame viewport clips its document; the replacement <div> 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 <div> 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;
|
||||
}
|
||||
@@ -41,7 +41,16 @@ export type RevealSpec = {
|
||||
cap: string; // data-cid-cap of a scroll-revealed element
|
||||
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
|
||||
transform: string; // its hidden transform (slide/scale offset), or "none"
|
||||
transition: string; // the transition to animate the reveal with
|
||||
transition: string; // the transition to animate the reveal with ("" for the visibility family)
|
||||
// visibility+entrance-class family (Elementor/WOW/AOS): hidden via `visibility:hidden`
|
||||
// pre-scroll, revealed by a class swap that applies a keyframe animation. The clone
|
||||
// re-hides with visibility (JS-applied, so non-JS/SSR still shows content) and replays
|
||||
// the named animation when scrolled into view.
|
||||
visibility?: "hidden";
|
||||
animationName?: string; // entrance @keyframes name in the revealed state (e.g. fadeInUp)
|
||||
animationDuration?: string; // e.g. "1.25s"
|
||||
animationDelay?: string; // e.g. "0s"
|
||||
animationTiming?: string; // e.g. "ease" / "cubic-bezier(...)"
|
||||
};
|
||||
|
||||
export type MarqueeSpec = {
|
||||
@@ -62,26 +71,41 @@ export type MotionCapture = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden
|
||||
* (opacity:0 / transform offset) and animate in when scrolled into view; by the time the
|
||||
* settled snapshot is taken (after `autoScroll` has walked the page) they are already
|
||||
* revealed, so their hidden state must be sampled BEFORE the first scroll. Records, on
|
||||
* `window.__cloneReveal`, the pre-scroll opacity/transform/transition of every tagged
|
||||
* element that is hidden-but-boxed with a real transition — the candidate reveal set
|
||||
* `captureMotion` later confirms (kept only if the element ends up visible). Idempotent;
|
||||
* call once at the first viewport, after settle, before `autoScroll`.
|
||||
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden and
|
||||
* animate in when scrolled into view; by the time the settled snapshot is taken (after
|
||||
* the reveal-settling pass has walked the page) they are already revealed, so their
|
||||
* hidden state must be sampled BEFORE the first scroll. Records, on `window.__cloneReveal`,
|
||||
* two candidate families over tagged elements — the set `captureMotion` later confirms
|
||||
* (kept only if the element ends up visible):
|
||||
* - **transition** — opacity≈0 with a real opacity/transform transition (the reveal
|
||||
* animates via the transition already on the element);
|
||||
* - **visibility** — `visibility:hidden` with a real box (Elementor `.elementor-invisible`,
|
||||
* WOW/AOS wrappers), revealed by a class swap that APPLIES a keyframe animation. The
|
||||
* entrance animation only exists post-swap, so `captureMotion` reads it at confirm
|
||||
* time from the revealed computed style. Only the OUTERMOST hidden element is recorded
|
||||
* (visibility inherits — descendants are covered by the wrapper's reveal).
|
||||
* Idempotent; call once at the canonical width, after settle, before the first scroll.
|
||||
*/
|
||||
export async function probeReveals(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
const out: Record<string, { opacity: string; transform: string; transition: string }> = {};
|
||||
const out: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> = {};
|
||||
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
|
||||
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
|
||||
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { continue; }
|
||||
const op = parseFloat(cs.opacity || "1");
|
||||
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
|
||||
if (cs.visibility === "hidden") {
|
||||
// visibility family: record only the reveal ROOT (parent not also hidden).
|
||||
const p = (el as HTMLElement).parentElement;
|
||||
let parentHidden = false;
|
||||
try { parentHidden = !!p && getComputedStyle(p).visibility === "hidden"; } catch { /* ignore */ }
|
||||
if (parentHidden) continue;
|
||||
out[cap] = { opacity: cs.opacity || "1", transform: "none", transition: "", family: "visibility" };
|
||||
continue;
|
||||
}
|
||||
const op = parseFloat(cs.opacity || "1");
|
||||
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
|
||||
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
|
||||
const tp = (cs.transitionProperty || "").toLowerCase();
|
||||
const td = cs.transitionDuration || "0s";
|
||||
@@ -311,17 +335,41 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log
|
||||
|
||||
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
|
||||
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
|
||||
const reveals: Array<{ cap: string; opacity: string; transform: string; transition: string }> = [];
|
||||
const reveals: Array<{
|
||||
cap: string; opacity: string; transform: string; transition: string;
|
||||
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||
}> = [];
|
||||
try {
|
||||
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string }> }).__cloneReveal || {};
|
||||
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string; family?: "visibility" }> }).__cloneReveal || {};
|
||||
// First value of a comma-joined animation longhand; timing functions carry inner
|
||||
// commas (cubic-bezier/steps), so take the whole leading function when present.
|
||||
const first = (v: string): string => (/^\s*(cubic-bezier\([^)]*\)|steps\([^)]*\)|[^,]+)/.exec(v || "")?.[1] ?? "").trim();
|
||||
for (const cap of Object.keys(probed)) {
|
||||
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
|
||||
if (!el) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
|
||||
const p = probed[cap]!;
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 8) continue;
|
||||
reveals.push({ cap, ...probed[cap]! });
|
||||
if (p.family === "visibility") {
|
||||
if (cs.visibility === "hidden") continue; // never revealed → genuinely hidden content
|
||||
// Revealed via class swap. The swap's entrance animation is now in the computed
|
||||
// style (libraries keep the animated class); record it for replay.
|
||||
const name = first(cs.animationName || "none");
|
||||
reveals.push({
|
||||
cap, opacity: p.opacity, transform: "none", transition: "",
|
||||
visibility: "hidden",
|
||||
...(name && name !== "none" ? {
|
||||
animationName: name,
|
||||
animationDuration: first(cs.animationDuration) || "1s",
|
||||
animationDelay: first(cs.animationDelay) || "0s",
|
||||
animationTiming: first(cs.animationTimingFunction) || "ease",
|
||||
} : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
|
||||
reveals.push({ cap, opacity: p.opacity, transform: p.transform, transition: p.transition });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(promoteLazyMediaInPage),
|
||||
new Promise<number>((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<RevealSettleResult> {
|
||||
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<RevealSettleResult> {
|
||||
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<RevealSettleResult>((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<string, unknown>;
|
||||
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<number> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(neutralizePreRevealInPage),
|
||||
new Promise<number>((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<CarouselSettleResult> {
|
||||
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<CarouselSettleResult> {
|
||||
const empty: CarouselSettleResult = { roots: 0, normalized: 0, neutralizedAnims: 0 };
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(settleCarouselsInPage),
|
||||
new Promise<CarouselSettleResult>((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");
|
||||
}
|
||||
}
|
||||
@@ -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 <svg>
|
||||
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<strong> </strong>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;
|
||||
}
|
||||
|
||||
+8
-4
@@ -38,6 +38,9 @@ export type CloneResult = {
|
||||
/** A stable, timestamp-free path to the app (via the `runs/<site>/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<CloneResult> {
|
||||
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<CloneResult> {
|
||||
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<void> {
|
||||
// --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"]);
|
||||
|
||||
@@ -20,6 +20,9 @@ export type DoneSummaryInput = {
|
||||
framework: "next" | "vite";
|
||||
/** Stable path (a `runs/<site>/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:`);
|
||||
|
||||
@@ -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<string, string>, 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 <video>'s own src or any child <source src> materialized locally —
|
||||
* the clone can then ship the real file instead of the poster-only fallback. */
|
||||
function videoHasLocalSource(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): boolean {
|
||||
if (node.attrs.src && assetMap.get(resolveUrl(node.attrs.src, sourceUrl))) return true;
|
||||
return node.children.some((c) => !isTextChild(c) && c.tag === "source"
|
||||
&& !!c.attrs.src && !!assetMap.get(resolveUrl(c.attrs.src, sourceUrl)));
|
||||
}
|
||||
|
||||
/** Whether a <source> element still points at anything after asset rewriting
|
||||
* (materialized src, or ≥1 surviving srcset candidate). A source with none is
|
||||
* omitted rather than emitted pointing at placeholders. */
|
||||
function sourceHasLocalCandidate(c: IRNode, assetMap: Map<string, string>, sourceUrl: string): boolean {
|
||||
if (c.attrs.src && assetMap.get(resolveUrl(c.attrs.src, sourceUrl))) return true;
|
||||
if (c.attrs.srcset && keptSrcsetCandidates(c.attrs.srcset, assetMap, sourceUrl).length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Default single-page link rewrite: a clone is self-contained, so a link that points
|
||||
* back to the SOURCE origin is rewritten to an app-relative path (`/enterprise`) instead
|
||||
* of the absolute source URL (`https://www.source.com/enterprise`) — otherwise every nav
|
||||
@@ -284,39 +313,48 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
||||
const prim = ctx?.primitives?.get(node.id);
|
||||
if (prim) props.push(['"data-component"', JSON.stringify(prim)]);
|
||||
|
||||
// Stage 2: a <video> is rendered as its (first-frame) poster — a streamed source
|
||||
// has no deterministic frame and its request aborts at snapshot time. Drop the
|
||||
// streaming src + autoplay so only the poster paints; keep the poster (rewritten
|
||||
// to a local still below). <source>/<track> children are dropped in renderNode.
|
||||
// A <video> whose file materialized locally ships it, mirroring the captured
|
||||
// playback attrs (autoplay/loop/muted/playsInline) faithfully. Otherwise it is
|
||||
// rendered as its (first-frame) poster — a streamed source has no deterministic
|
||||
// frame and its request aborts at snapshot time — dropping the streaming src +
|
||||
// autoplay so only the poster paints; keep the poster (rewritten to a local
|
||||
// still below). <source>/<track> children are filtered in emitChildren.
|
||||
const isVideo = node.tag === "video";
|
||||
// An <iframe> embeds a third-party, non-deterministic document; reproducing it
|
||||
const videoLocal = isVideo && videoHasLocalSource(node, assetMap, sourceUrl);
|
||||
// An <iframe> embeds a third-party, non-deterministic document; reproducing it live
|
||||
// would break self-containment (rubric Gate 2) and can't be deterministic anyway.
|
||||
// Keep the element as a placeholder sized by its captured box, but drop the
|
||||
// document-loading attrs so it paints an empty frame instead of pulling content.
|
||||
// When capture grafted the embedded document's subtree (graft.ts) the node renders as
|
||||
// a <div> with those children (see resolveTag) — drop the frame-only attrs entirely
|
||||
// (width/height would be invalid on a div; CSS keys the box). Otherwise keep the
|
||||
// element as a placeholder sized by its captured box, minus the document-loading
|
||||
// attrs, so it paints an empty frame instead of pulling content.
|
||||
const isIframe = node.tag === "iframe";
|
||||
const iframeGrafted = isIframe && node.children.some((c) => !isTextChild(c));
|
||||
const attrKeys = Object.keys(node.attrs).sort();
|
||||
for (const key of attrKeys) {
|
||||
let value = node.attrs[key]!;
|
||||
if (key === "class" || key === "style" || key === "data-cid-cap") continue;
|
||||
if (isVideo && (key === "src" || key === "autoplay" || key === "loop" || key === "preload")) continue;
|
||||
if (isVideo && !videoLocal && (key === "src" || key === "autoplay" || key === "loop" || key === "preload")) continue;
|
||||
if (isIframe && (key === "src" || key === "srcdoc" || key === "name")) continue;
|
||||
if (iframeGrafted && (key === "width" || key === "height")) continue;
|
||||
|
||||
if (ASSET_ATTRS.has(key)) {
|
||||
const abs = resolveUrl(value, sourceUrl);
|
||||
const local = assetMap.get(abs);
|
||||
value = local ?? TRANSPARENT_GIF; // never point back to a remote origin
|
||||
// A poster that missed the asset map is DROPPED — a guaranteed-blank overlay
|
||||
// hides the element's own background, strictly worse than showing it. A missed
|
||||
// video/source src likewise (a placeholder is not playable; a local <source>
|
||||
// child may still carry the file). Only <img> keeps the transparent-GIF
|
||||
// fallback: never point back to a remote origin.
|
||||
if (!local && (key === "poster" || node.tag === "video" || node.tag === "source")) continue;
|
||||
value = local ?? TRANSPARENT_GIF;
|
||||
} else if (key === "srcset") {
|
||||
// Keep only candidates we actually materialized; drop the rest. Lazy-load
|
||||
// libraries seed srcset with 1x1 placeholders (data: GIFs) and swap in the
|
||||
// real URLs via JS — replaying those placeholders would beat the rewritten
|
||||
// `src` (srcset wins over src) and paint a blank box. If nothing survives,
|
||||
// omit srcset so the browser falls back to the real local `src`.
|
||||
const kept = 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);
|
||||
const kept = keptSrcsetCandidates(value, assetMap, sourceUrl);
|
||||
if (kept.length === 0) continue;
|
||||
value = kept.join(", ");
|
||||
} else if (key === "href") {
|
||||
@@ -340,7 +378,7 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
|
||||
if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
|
||||
|
||||
if (node.rawHTML && node.tag === "svg") {
|
||||
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
|
||||
@@ -504,6 +542,10 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
|
||||
const disp = (node.computedByVp[1280] ?? Object.values(node.computedByVp)[0])?.display ?? "";
|
||||
tag = /inline(?!-block|-flex|-grid)/.test(disp) ? "span" : "div";
|
||||
}
|
||||
// An iframe with grafted children (capture/graft.ts) is a real subtree, not an embed:
|
||||
// children inside <iframe> are unrendered fallback content, so emit a <div> container
|
||||
// carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical).
|
||||
if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div";
|
||||
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
|
||||
if (violatesContentModel(node, tag)) tag = "div";
|
||||
return tag;
|
||||
@@ -579,7 +621,11 @@ function emitChildren(children: IRChild[], parentTag: string | null, assetMap: M
|
||||
textBuf += c.text;
|
||||
continue;
|
||||
}
|
||||
if (parentTag === "video" && (c.tag === "source" || c.tag === "track")) continue;
|
||||
if (parentTag === "video" && c.tag === "track") continue; // caption files are not captured
|
||||
// A <picture>/<video> `<source>` is emitted only when a candidate materialized
|
||||
// locally: a video source otherwise falls back to poster-only rendering, a
|
||||
// picture source must never point its media band at a placeholder.
|
||||
if ((parentTag === "video" || parentTag === "picture") && c.tag === "source" && !sourceHasLocalCandidate(c, assetMap, sourceUrl)) continue;
|
||||
// Section split: a section-root child is hoisted into its own module and replaced
|
||||
// by a `<HeroSection />` placeholder. Rendered once (subtree → module body); the
|
||||
// composed DOM is identical to inlining (same tags/cids/classes).
|
||||
@@ -1117,8 +1163,11 @@ function emitVariantSkeleton(componentName: string, instances: IRNode[], variant
|
||||
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
||||
continue;
|
||||
}
|
||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "source") continue;
|
||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
||||
// Mirror emitChildren: a <source> child survives only with a materialized candidate
|
||||
// (judged on the representative — instances share the capture's asset set).
|
||||
if ((tag === "video" || tag === "picture") && (repr.children[i] as IRNode).tag === "source"
|
||||
&& !sourceHasLocalCandidate(repr.children[i] as IRNode, assetMap, sourceUrl)) continue;
|
||||
flushText();
|
||||
const subNodes = instances.map((n) => n.children[i] as IRNode);
|
||||
const sub = emitVariantSkeleton(componentName, subNodes, variants, childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, slots, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
||||
@@ -1498,8 +1547,11 @@ function emitSkeleton(instances: IRNode[], insideInteractive: boolean, indent: n
|
||||
instances.forEach((n, k) => { runText![k] += (n.children[i] as IRTextNode).text; });
|
||||
continue;
|
||||
}
|
||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "source") continue;
|
||||
if (tag === "video" && (repr.children[i] as IRNode).tag === "track") continue;
|
||||
// Mirror emitChildren: a <source> child survives only with a materialized candidate
|
||||
// (judged on the representative — instances share the capture's asset set).
|
||||
if ((tag === "video" || tag === "picture") && (repr.children[i] as IRNode).tag === "source"
|
||||
&& !sourceHasLocalCandidate(repr.children[i] as IRNode, assetMap, sourceUrl)) continue;
|
||||
flushText();
|
||||
const sub = emitSkeleton(instances.map((n) => n.children[i] as IRNode), childInteractive, indent + 1, dataRows, styleRows, cids, gen, styleGen, assetMap, sourceUrl, ctx, childTable, [...ancestors, repr.tag]);
|
||||
if (sub === null) return null;
|
||||
@@ -2478,6 +2530,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
const globals = tailwindGlobalsCss({
|
||||
reset: RESET_CSS, fontCss: fontGraph.css, tokensCss: tokensCss + (tw.colorDefsCss ? "\n" + tw.colorDefsCss : ""),
|
||||
htmlBg, bodyFont: SYSTEM_FALLBACK, clip, colorTokens: tw.colorTokens, viewports: ir.doc.viewports,
|
||||
canonical: ir.doc.canonicalViewport,
|
||||
});
|
||||
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
||||
} else {
|
||||
@@ -2685,6 +2738,8 @@ const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
// The dev-tools badge would leak into reviewer/validator screenshots.
|
||||
devIndicators: false,
|
||||
};
|
||||
export default nextConfig;
|
||||
`;
|
||||
|
||||
@@ -44,6 +44,7 @@ function signature(nr: NodeRule): string {
|
||||
for (const b of nr.bands) s += `@${b.media}{${serDecls(b.decls)}}`;
|
||||
if (nr.before) { s += "::before{" + serDecls(nr.before.base); for (const b of nr.before.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||
if (nr.after) { s += "::after{" + serDecls(nr.after.base); for (const b of nr.after.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||
if (nr.placeholder) { s += "::placeholder{" + serDecls(nr.placeholder.base); for (const b of nr.placeholder.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@ function splitRule(nr: NodeRule): { typo: NodeRule; layout: NodeRule; box: NodeR
|
||||
const b0 = partition(nr.base);
|
||||
const typo: NodeRule = { base: b0.typo, bands: [] };
|
||||
const layout: NodeRule = { base: b0.layout, bands: [] };
|
||||
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after };
|
||||
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after, placeholder: nr.placeholder };
|
||||
for (const band of nr.bands) {
|
||||
const bs = partition(band.decls);
|
||||
if (bs.typo.size) typo.bands.push({ media: band.media, decls: bs.typo });
|
||||
|
||||
@@ -91,6 +91,11 @@ const GENERIC: Array<{ prop: string; def: string | string[] }> = [
|
||||
{ prop: "bottom", def: "auto" }, { prop: "left", def: "auto" },
|
||||
{ prop: "float", def: "none" }, { prop: "clear", def: "none" },
|
||||
{ prop: "zIndex", def: "auto" },
|
||||
// visibility is INHERITED, so the parent-equality skip keeps descendants of a
|
||||
// hidden subtree clean; without this entry a node hidden at the canonical
|
||||
// viewport could never emit `visibility:hidden` at all (the only other path is
|
||||
// per-band and gated on being shown at base).
|
||||
{ prop: "visibility", def: "visible" },
|
||||
{ prop: "opacity", def: "1" }, { prop: "isolation", def: "auto" },
|
||||
{ prop: "mixBlendMode", def: "normal" },
|
||||
{ prop: "minWidth", def: ["0px", "auto"] }, { prop: "maxWidth", def: "none" },
|
||||
@@ -2305,7 +2310,14 @@ function declsForViewport(
|
||||
}
|
||||
|
||||
// Inherited: skip when equal to parent's value (inheritance handles it).
|
||||
if (INHERITED.has(prop) && parentComputed && parentComputed[prop] === value) continue;
|
||||
// Exception: the reset (`ul, ol, menu { list-style: none; }`) breaks the list-marker
|
||||
// inheritance chain on those tags, so parent-equality is not a safe elision there —
|
||||
// a source <ul> with `disc` equals its parent's initial `disc` yet must still emit
|
||||
// or the reset erases the markers. <li> inherits from the ul, which now emits.
|
||||
if (INHERITED.has(prop) && parentComputed && parentComputed[prop] === value) {
|
||||
const listMarkerReset = (prop === "listStyleType" || prop === "listStylePosition") && /^(ul|ol|menu)$/.test(tag);
|
||||
if (!listMarkerReset) continue;
|
||||
}
|
||||
|
||||
let outValue = value;
|
||||
if (prop === "backgroundImage" || prop === "maskImage" || prop === "filter" || prop === "clipPath") {
|
||||
@@ -2586,7 +2598,41 @@ function pseudoDecls(style: StyleMap, assetMap: Map<string, string>): Map<string
|
||||
// the grouped class produces identical computed styles (fidelity-neutral).
|
||||
export type BandRule = { media: string; decls: Map<string, string> };
|
||||
export type PseudoRule = { base: Map<string, string>; bands: BandRule[] };
|
||||
export type NodeRule = { base: Map<string, string>; bands: BandRule[]; before?: PseudoRule; after?: PseudoRule };
|
||||
export type NodeRule = { base: Map<string, string>; bands: BandRule[]; before?: PseudoRule; after?: PseudoRule; placeholder?: PseudoRule };
|
||||
|
||||
/** ::placeholder declarations for a form control. Color always emits (the UA default is
|
||||
* its own gray, NOT inherited from the input, so equality with the host proves nothing);
|
||||
* font/spacing props DO inherit from the input inside the pseudo, so they emit only when
|
||||
* they differ from the host's own computed value. */
|
||||
function placeholderDecls(style: StyleMap, host: StyleMap | undefined): Map<string, string> {
|
||||
const decls = new Map<string, string>();
|
||||
if (style.color) decls.set("color", style.color);
|
||||
if (style.opacity && style.opacity !== "1") decls.set("opacity", style.opacity);
|
||||
for (const prop of ["fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "textTransform"]) {
|
||||
const v = style[prop];
|
||||
if (!v || (host && host[prop] === v)) continue;
|
||||
decls.set(kebab(prop), v);
|
||||
}
|
||||
return decls;
|
||||
}
|
||||
|
||||
/** Banded ::placeholder rule (mirrors collectPseudoRule's base+delta shape). */
|
||||
function collectPlaceholderRule(styleByVp: Record<number, StyleMap>, hostByVp: Record<number, StyleMap>, baseVp: number, bands: Band[], tokenResolver?: TokenResolver): PseudoRule | undefined {
|
||||
const baseStyle = styleByVp[baseVp] ?? Object.values(styleByVp)[0];
|
||||
if (!baseStyle) return undefined;
|
||||
const out: PseudoRule = { base: finalizeDecls(placeholderDecls(baseStyle, hostByVp[baseVp]), tokenResolver), bands: [] };
|
||||
for (const b of bands) {
|
||||
if (!b.media) continue;
|
||||
const st = styleByVp[b.vp];
|
||||
if (!st) continue; // control not rendered at this width — the host rule already hides it
|
||||
const vpDecls = finalizeDecls(placeholderDecls(st, hostByVp[b.vp]), tokenResolver);
|
||||
const delta = new Map<string, string>();
|
||||
for (const [k, v] of vpDecls) if (out.base.get(k) !== v) delta.set(k, v);
|
||||
for (const [k] of out.base) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
|
||||
if (delta.size > 0) out.bands.push({ media: b.media, decls: delta });
|
||||
}
|
||||
return out.base.size > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/** Collect a pseudo-element's banded rule (its size/position can be responsive —
|
||||
* e.g. flex spacer pseudo-elements in horizontal scrollers). `hostContentWidthByVp`
|
||||
@@ -2940,20 +2986,47 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
||||
// Node was not in the observed DOM at this width (responsive conditional
|
||||
// rendering): hide it so the clone matches the source at this viewport.
|
||||
if (!node.computedByVp[b.vp]) { nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; }
|
||||
// The node isn't PAINTED at this width — either it goes `display:none` itself, or an ancestor
|
||||
// hides the whole subtree. Its box renders nothing, so any width/height/inset/padding override
|
||||
// here is invisible: pure breakpoint noise. Emit ONLY the hide, and only when the node ITSELF
|
||||
// is the one turning off (was shown at base); if an ancestor hides it, emit nothing at all —
|
||||
// that ancestor's own `display:none` already removes this node with it.
|
||||
const ownNone = (node.computedByVp[b.vp]?.display || "") === "none";
|
||||
if (ownNone || !node.visibleByVp[b.vp]) {
|
||||
// The node isn't PAINTED at this width. HOW it is hidden decides what to emit, because only
|
||||
// `display:none` takes the box out of layout — a `visibility:hidden` box still occupies space
|
||||
// and can extend the scrollable area. The base rule bakes CANONICAL geometry unconditionally,
|
||||
// so skipping every override here can park e.g. a desktop `left:548px` slider arrow inside a
|
||||
// 375px viewport: invisible, but +210px of sideways scroll.
|
||||
const vpCs = node.computedByVp[b.vp]!;
|
||||
const ownNone = (vpCs.display || "") === "none";
|
||||
// The node ITSELF is visibility:hidden here (its parent isn't → not inherited from an
|
||||
// ancestor whose own rule already carries the hide).
|
||||
const ownHidden = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "") &&
|
||||
!/^(hidden|collapse)$/.test(parentNode?.computedByVp[b.vp]?.visibility || "");
|
||||
if (ownHidden) {
|
||||
const bb = node.bboxByVp[b.vp];
|
||||
if (!bb || bb.width <= 0 || bb.height <= 0) {
|
||||
// The hidden box occupied NOTHING in the capture (0×0 — e.g. an uninitialised swiper
|
||||
// arrow collapsed by its container). `display:none` reproduces "invisible, takes no
|
||||
// space" exactly and guarantees it cannot extend scroll bounds at this width.
|
||||
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||
continue;
|
||||
}
|
||||
// Non-zero bbox: the invisible box occupies real layout space. Fall through to the normal
|
||||
// per-viewport delta so it sits where the capture measured it at THIS width — not at the
|
||||
// canonical geometry the base rule baked. The hide itself rides along in the delta
|
||||
// (declsForViewport emits `visibility:hidden`; parent-equality keeps it own-only).
|
||||
} else if (ownNone || !node.visibleByVp[b.vp]) {
|
||||
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
|
||||
if (ownNone && shownAtBase) nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||
else if (shownAtBase) {
|
||||
const vpCs = node.computedByVp[b.vp];
|
||||
if (ownNone) {
|
||||
// Own display:none removes the box from layout entirely. Emit the hide even when the node
|
||||
// is ALSO hidden at base — a visibility:hidden base still bakes an OCCUPYING box (see
|
||||
// above), so without this band the canonical geometry would render at this width. Skip
|
||||
// only when the base itself is display:none (the band would be redundant).
|
||||
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
|
||||
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||
}
|
||||
} else if (shownAtBase) {
|
||||
// Hidden by an ancestor (or zero-size / opacity:0): geometry overrides are breakpoint
|
||||
// noise — the ancestor's own hide (or the reveal replay, for scroll-reveal opacity)
|
||||
// covers it. Emit only the hide the node itself carries.
|
||||
const hide = new Map<string, string>();
|
||||
if (vpCs && pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
||||
if (vpCs && /^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
||||
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
||||
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
||||
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
|
||||
}
|
||||
continue;
|
||||
@@ -2997,6 +3070,9 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
||||
nr.after = collectPseudoRule(node.afterByVp, baseVp, bands, assetMap, tokenResolver, hostCW, padW, padH);
|
||||
}
|
||||
}
|
||||
if (node.placeholderByVp) {
|
||||
nr.placeholder = collectPlaceholderRule(node.placeholderByVp, node.computedByVp, baseVp, bands, tokenResolver);
|
||||
}
|
||||
rules.set(node.id, nr);
|
||||
|
||||
for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight);
|
||||
@@ -3026,9 +3102,11 @@ export function assembleCss(
|
||||
if (nr.base.size > 0) baseRules.push(formatRule(sel, nr.base));
|
||||
if (nr.before) baseRules.push(formatRule(`${sel}::before`, nr.before.base));
|
||||
if (nr.after) baseRules.push(formatRule(`${sel}::after`, nr.after.base));
|
||||
if (nr.placeholder) baseRules.push(formatRule(`${sel}::placeholder`, nr.placeholder.base));
|
||||
for (const b of nr.bands) bandRules.get(b.media)?.push(formatRule(sel, b.decls));
|
||||
if (nr.before) for (const b of nr.before.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::before`, b.decls));
|
||||
if (nr.after) for (const b of nr.after.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::after`, b.decls));
|
||||
if (nr.placeholder) for (const b of nr.placeholder.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::placeholder`, b.decls));
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (keyframes) parts.push(keyframes);
|
||||
|
||||
@@ -19,7 +19,13 @@ import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } f
|
||||
|
||||
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||
export type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
||||
// Reveal families: transition (hidden via opacity/transform, revealed by the element's own
|
||||
// transition) and visibility+entrance-class (hidden via visibility, revealed with a named
|
||||
// keyframe animation — Elementor/WOW/AOS; the @keyframes ship in the page CSS).
|
||||
export type RTReveal = {
|
||||
cid: string; opacity: string; transform: string; transition: string;
|
||||
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||
};
|
||||
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||
|
||||
@@ -58,7 +64,16 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu
|
||||
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
|
||||
const cid = map.get(rv.cap);
|
||||
if (!ok(cid)) continue;
|
||||
reveals.push({ cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition });
|
||||
reveals.push({
|
||||
cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition,
|
||||
...(rv.visibility === "hidden" ? { visibility: rv.visibility } : {}),
|
||||
...(rv.animationName ? {
|
||||
animationName: rv.animationName,
|
||||
animationDuration: rv.animationDuration,
|
||||
animationDelay: rv.animationDelay,
|
||||
animationTiming: rv.animationTiming,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
const marquees: RTMarquee[] = [];
|
||||
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
|
||||
@@ -92,7 +107,10 @@ import { useEffect } from "react";
|
||||
|
||||
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||
type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
||||
type RTReveal = {
|
||||
cid: string; opacity: string; transform: string; transition: string;
|
||||
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
|
||||
};
|
||||
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||
|
||||
@@ -111,7 +129,8 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||
const anims: Animation[] = [];
|
||||
const revealed: Array<() => void> = []; // per-reveal "show now" fns (also the cleanup)
|
||||
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
|
||||
const revealed: Array<(animate: boolean) => void> = [];
|
||||
let io: IntersectionObserver | null = null;
|
||||
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -153,29 +172,59 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
|
||||
}
|
||||
|
||||
// Scroll reveals: hide each element (opacity/transform) with the captured transition,
|
||||
// then reveal (clear the inline overrides → transitions to the base CSS) when it scrolls
|
||||
// into view. A force-reveal timer guarantees nothing stays hidden if the observer misses.
|
||||
// Scroll reveals: re-hide each element (JS-applied on mount, so non-JS/SSR still shows
|
||||
// the content), then reveal when it scrolls into view. Two families:
|
||||
// - transition — hide via opacity/transform, reveal by transitioning to full;
|
||||
// - visibility+entrance-class (Elementor/WOW/AOS) — hide via visibility with the baked
|
||||
// entrance animation suppressed, reveal by restarting the captured @keyframes
|
||||
// (they ship in the page CSS under the captured animation-name).
|
||||
// A force-reveal timer guarantees nothing stays hidden if the observer misses.
|
||||
if (spec.reveals.length) {
|
||||
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
|
||||
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
|
||||
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
|
||||
const show = (el: HTMLElement) => { el.style.opacity = "1"; el.style.transform = "none"; };
|
||||
const byEl = new Map<Element, HTMLElement>();
|
||||
// animate=false (validator settle path) jumps straight to the settled frame so no
|
||||
// measurement can catch a mid-entrance value.
|
||||
const show = (el: HTMLElement, rv: RTReveal, animate: boolean) => {
|
||||
el.style.opacity = "1"; el.style.transform = "none";
|
||||
if (rv.visibility === "hidden") el.style.visibility = "visible";
|
||||
if (rv.animationName) {
|
||||
if (animate) {
|
||||
// restart the entrance from t=0: none -> name starts a fresh animation
|
||||
el.style.animationName = "none";
|
||||
void el.offsetWidth;
|
||||
el.style.animationName = rv.animationName;
|
||||
el.style.animationDuration = rv.animationDuration || "1s";
|
||||
el.style.animationDelay = rv.animationDelay || "0s";
|
||||
el.style.animationTimingFunction = rv.animationTiming || "ease";
|
||||
el.style.animationFillMode = "both";
|
||||
el.style.animationIterationCount = "1";
|
||||
} else {
|
||||
el.style.animationName = "none"; // settled frame: keep the entrance suppressed
|
||||
}
|
||||
}
|
||||
};
|
||||
const shows = new Map<Element, (animate: boolean) => void>();
|
||||
for (const rv of spec.reveals) {
|
||||
const el = byCid(rv.cid);
|
||||
if (!el) continue;
|
||||
el.style.transition = rv.transition;
|
||||
el.style.opacity = rv.opacity;
|
||||
if (rv.transform !== "none") el.style.transform = rv.transform;
|
||||
byEl.set(el, el);
|
||||
revealed.push(() => show(el));
|
||||
if (rv.visibility === "hidden") {
|
||||
el.style.visibility = "hidden";
|
||||
el.style.animationName = "none"; // don't burn the baked entrance while hidden
|
||||
} else {
|
||||
el.style.transition = rv.transition;
|
||||
el.style.opacity = rv.opacity;
|
||||
if (rv.transform !== "none") el.style.transform = rv.transform;
|
||||
}
|
||||
const fn = (animate: boolean) => show(el, rv, animate);
|
||||
shows.set(el, fn);
|
||||
revealed.push(fn);
|
||||
}
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) if (e.isIntersecting) { const el = byEl.get(e.target); if (el) { show(el); io!.unobserve(e.target); } }
|
||||
for (const e of entries) if (e.isIntersecting) { const f = shows.get(e.target); if (f) { f(true); io!.unobserve(e.target); } }
|
||||
}, { rootMargin: "0px 0px -8% 0px" });
|
||||
for (const el of byEl.keys()) io.observe(el);
|
||||
forceTimer = setTimeout(() => { for (const f of revealed) f(); }, 4000);
|
||||
for (const el of shows.keys()) io.observe(el);
|
||||
forceTimer = setTimeout(() => { for (const f of revealed) f(true); }, 4000);
|
||||
}
|
||||
|
||||
const stopAll = () => {
|
||||
@@ -185,7 +234,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
||||
if (io) io.disconnect();
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
for (const f of revealed) f(); // reveal everything → base CSS settled frame
|
||||
for (const f of revealed) f(false); // reveal everything, settled → base CSS graded frame
|
||||
};
|
||||
// Measurement hook: restore the fully-settled/revealed base for grading.
|
||||
(window as any).__dittoMotionStop = stopAll;
|
||||
|
||||
@@ -273,6 +273,15 @@ function iconUrl(icon: SeoInventory["icons"][number]): string {
|
||||
return icon.localPath || icon.href;
|
||||
}
|
||||
|
||||
// Next validates metadata.openGraph.type against this fixed enum and THROWS at
|
||||
// render on anything else (e.g. Shopify's `product.group`). Unsupported values
|
||||
// are emitted via metadata.other instead so the tag survives without the crash.
|
||||
const NEXT_OG_TYPES = new Set([
|
||||
"website", "article", "book", "profile",
|
||||
"music.song", "music.album", "music.playlist", "music.radio_station",
|
||||
"video.movie", "video.episode", "video.tv_show", "video.other",
|
||||
]);
|
||||
|
||||
function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
|
||||
if (report.description) metadata.description = report.description;
|
||||
@@ -297,9 +306,13 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
const ogSiteName = firstValue(ogEntries, "og:site_name");
|
||||
const ogUrl = firstValue(ogEntries, "og:url");
|
||||
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
|
||||
const other: Record<string, string> = {};
|
||||
if (ogTitle) og.title = ogTitle;
|
||||
if (ogDescription) og.description = ogDescription;
|
||||
if (ogType) og.type = ogType;
|
||||
if (ogType) {
|
||||
if (NEXT_OG_TYPES.has(ogType)) og.type = ogType;
|
||||
else other["og:type"] = ogType;
|
||||
}
|
||||
if (ogSiteName) og.siteName = ogSiteName;
|
||||
if (ogUrl) og.url = ogUrl;
|
||||
if (ogImages.length) og.images = ogImages;
|
||||
@@ -335,6 +348,7 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
}
|
||||
if (Object.keys(icons).length) metadata.icons = icons;
|
||||
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
|
||||
if (Object.keys(other).length) metadata.other = other;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,14 @@ const SPACE_SCALE = new Set<string>([
|
||||
]);
|
||||
// Props that take 100% → the `-full` named utility (exact: w-full ≡ width:100%).
|
||||
const PCT_FULL = new Set<string>(["width", "height", "min-width", "max-width", "min-height", "max-height"]);
|
||||
// ARB props whose named Tailwind v4 scale really includes a bare `0` step: the spacing-scale props
|
||||
// (w-0, top-0, leading-0, indent-0, …) plus the numeric scales grow/shrink/basis/order/z/opacity.
|
||||
// font-size, letter-spacing, the radius corners and object-position have NO `-0` utility — `text-0`
|
||||
// / `tracking-0` compile to NOTHING in v4 (a silent no-op: a map label captured at font-size:0
|
||||
// painted at the inherited 20px) — so their zeros must stay arbitrary (`text-[0px]`).
|
||||
const ZERO_NAMED = new Set<string>([
|
||||
...SPACE_SCALE, "flex-grow", "flex-shrink", "flex-basis", "order", "z-index", "opacity",
|
||||
]);
|
||||
const ORIGIN_NAMED: Record<string, string> = {
|
||||
center: "origin-center", top: "origin-top", "top right": "origin-top-right", right: "origin-right",
|
||||
"bottom right": "origin-bottom-right", bottom: "origin-bottom", "bottom left": "origin-bottom-left",
|
||||
@@ -141,8 +149,9 @@ function transformNeedsOrigin(v: string | undefined): boolean {
|
||||
return !!v && v !== "none" && translateOffsets(v) === null;
|
||||
}
|
||||
|
||||
/** Translate ONE computed declaration into a Tailwind utility (no responsive prefix). */
|
||||
function declToUtil(prop: string, value: string): string {
|
||||
/** Translate ONE computed declaration into a Tailwind utility (no responsive prefix).
|
||||
* Exported for tests (the zero-value gating is load-bearing: an invalid utility is a SILENT no-op). */
|
||||
export function declToUtil(prop: string, value: string): string {
|
||||
// Colors → named theme tokens when tokenized (bg-primary), else arbitrary value.
|
||||
if (prop === "color") { const n = tokenName(value); return n ? `text-${n}` : `text-[color:${arb(value)}]`; }
|
||||
if (prop === "background-color") { const n = tokenName(value); return n ? `bg-${n}` : `bg-[${arb(value)}]`; }
|
||||
@@ -229,7 +238,11 @@ function declToUtil(prop: string, value: string): string {
|
||||
const n = spacingSteps(value);
|
||||
if (n !== null) return n < 0 ? `-${ARB[prop]}-${-n}` : `${ARB[prop]}-${n}`;
|
||||
}
|
||||
if (value === "0" || value === "0px" || value === "0rem") return `${ARB[prop]}-0`; // bare/unitless zero
|
||||
// Bare/unitless zero — the named `-0` only where that utility actually exists (ZERO_NAMED);
|
||||
// elsewhere emit the exact arbitrary length so the declaration really compiles.
|
||||
if (value === "0" || value === "0px" || value === "0rem") {
|
||||
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
|
||||
}
|
||||
return `${ARB[prop]}-[${arb(value)}]`;
|
||||
}
|
||||
if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
|
||||
@@ -261,25 +274,47 @@ function declToUtil(prop: string, value: string): string {
|
||||
|
||||
/** Responsive variant prefix for a band's media query. For the standard capture ladder
|
||||
* (375/768/1280/1920, canonical 1280) the per-viewport bands are the midpoints 571/1024/1600,
|
||||
* which we map to the CONVENTIONAL Tailwind breakpoints a human would author — `max-md:`
|
||||
* (mobile, <768), `md:max-lg:` (tablet, 768–1023), `2xl:` (wide, ≥1536) — leaving 1280 as the
|
||||
* unprefixed base. Each reproduces the captured width EXACTLY (the style gate measures only at
|
||||
* 375/768/1280/1920), while between-width behaviour follows the standard breakpoints instead of
|
||||
* arbitrary midpoint pixels. Non-standard viewport sets fall back to the exact arbitrary band. */
|
||||
function prefixFor(media: string): string {
|
||||
* which we map to the CONVENTIONAL Tailwind breakpoint NAMES a human would author — `max-md:`
|
||||
* (mobile), `md:max-lg:` (tablet), `2xl:` (wide) — leaving 1280 as the unprefixed base. The
|
||||
* generated @theme redefines those screens to the band boundaries (bandScreens), so every named
|
||||
* variant flips at EXACTLY the same width as the ditto.css band media query (Tailwind's stock
|
||||
* 768/1024/1536 would open windows — e.g. 1536–1600 — where utilities and ditto.css rules
|
||||
* disagree). Non-standard viewport sets fall back to the exact arbitrary band. */
|
||||
export function prefixFor(media: string): string {
|
||||
const min = /min-width:\s*(\d+)px/.exec(media);
|
||||
const max = /max-width:\s*(\d+)px/.exec(media);
|
||||
const lo = min ? +min[1]! : 0;
|
||||
const hi = max ? +max[1]! : Infinity;
|
||||
if (!min && hi === 571) return "max-md:"; // 375 sample → below md (768)
|
||||
if (lo === 572 && hi === 1024) return "md:max-lg:"; // 768 sample → md … below lg (1024)
|
||||
if (lo === 1601 && !max) return "2xl:"; // 1920 sample → at/above 2xl (1536)
|
||||
if (!min && hi === 571) return "max-md:"; // 375 sample band (md pinned to 572)
|
||||
if (lo === 572 && hi === 1024) return "md:max-lg:"; // 768 sample band (lg pinned to 1025)
|
||||
if (lo === 1601 && !max) return "2xl:"; // 1920 sample band (2xl pinned to 1601)
|
||||
let p = "";
|
||||
if (min) p += `min-[${lo}px]:`;
|
||||
if (max) p += `max-[${hi}px]:`;
|
||||
// v4 max-* is STRICT (`width < X`) while the band's max-width is inclusive → pin to hi+1.
|
||||
if (max) p += `max-[${hi + 1}px]:`;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Named-screen overrides for the generated @theme, derived from the SAME bands prefixFor
|
||||
* names. Invariant: for every band, the Tailwind variant prefix and the ditto.css media
|
||||
* query produce the same min/max boundaries. Tailwind v4 compiles `X:` to `width >= X`
|
||||
* and `max-X:` to `width < X`, so a band min pins the screen directly and a band max pins
|
||||
* it to max+1. Bands prefixFor leaves arbitrary (`min-[…px]:`) embed their exact bounds
|
||||
* and need no override. */
|
||||
export function bandScreens(viewports: number[], canonical: number): Array<[string, number]> {
|
||||
const out = new Map<string, number>();
|
||||
for (const b of computeBands(viewports, canonical)) {
|
||||
if (!b.media) continue;
|
||||
const min = /min-width:\s*(\d+)px/.exec(b.media);
|
||||
const max = /max-width:\s*(\d+)px/.exec(b.media);
|
||||
for (const m of prefixFor(b.media).matchAll(/(max-)?(sm|md|lg|xl|2xl):/g)) {
|
||||
out.set(m[2]!, m[1] ? +max![1]! + 1 : +min![1]!);
|
||||
}
|
||||
}
|
||||
const order = ["sm", "md", "lg", "xl", "2xl"];
|
||||
return [...out.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
|
||||
}
|
||||
|
||||
function fmtRule(sel: string, decls: Map<string, string>): string {
|
||||
return `${sel}{${[...decls].map(([k, v]) => `${k}:${v}`).join(";")}}`;
|
||||
}
|
||||
@@ -1141,6 +1176,13 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
||||
classOf.set(cid, dedupeUtils([...(classOf.get(cid) ?? "").split(" ").filter(Boolean), ...filteredPseudoUtils]).join(" "));
|
||||
utilCount += filteredPseudoUtils.length;
|
||||
}
|
||||
// ::placeholder → a raw [data-cid] ditto.css rule (rare — only styled form controls),
|
||||
// matching the pseudo fallback path above: exact colors, no Tailwind-escape round-trip.
|
||||
if (nr.placeholder) {
|
||||
const psel = `${sel}::placeholder`;
|
||||
if (nr.placeholder.base.size) extraParts.push(fmtRule(psel, nr.placeholder.base));
|
||||
for (const b of nr.placeholder.bands) extraParts.push(`${b.media} {\n${fmtRule(psel, b.decls)}\n}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4 hover/focus → Tailwind variant utilities folded into each node's className (replacing the
|
||||
@@ -1181,9 +1223,14 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
||||
* so utilities override them. */
|
||||
export function tailwindGlobalsCss(opts: {
|
||||
reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string;
|
||||
clip: string; colorTokens: string[]; viewports: number[];
|
||||
clip: string; colorTokens: string[]; viewports: number[]; canonical: number;
|
||||
}): string {
|
||||
const screens = [...opts.viewports].sort((a, b) => a - b).map((v) => ` --breakpoint-vp${v}: ${v}px;`).join("\n");
|
||||
const screens = [
|
||||
...[...opts.viewports].sort((a, b) => a - b).map((v) => ` --breakpoint-vp${v}: ${v}px;`),
|
||||
// Named screens pinned to the ditto.css band boundaries so `md:`/`2xl:` utilities
|
||||
// and the banded rules flip at the same width (see bandScreens).
|
||||
...bandScreens(opts.viewports, opts.canonical).map(([n, px]) => ` --breakpoint-${n}: ${px}px;`),
|
||||
].join("\n");
|
||||
const colors = opts.colorTokens.map((n) => ` --color-${n}: var(--${n});`).join("\n");
|
||||
return `@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
|
||||
@@ -96,11 +96,9 @@ export function materializeAssets(graph: AssetGraph, sourceDir: string, appPubli
|
||||
for (const e of graph.entries) {
|
||||
if (e.classification !== "downloaded" || !e.storedFile || !e.localPath) continue;
|
||||
if (e.type === "css") continue; // not served by the app
|
||||
// A <video> is rendered as its poster still (streaming sources are dropped in
|
||||
// generation), so the materialized video file is never referenced — skip
|
||||
// copying it to public/ to save disk and build time. Its first-frame poster is
|
||||
// a separate image asset that is still materialized.
|
||||
if (e.type === "video") continue;
|
||||
// Video files ARE copied: generation emits a local <video src>/<source src>
|
||||
// whenever the file materialized (only streaming/undownloaded videos fall back
|
||||
// to poster-only rendering).
|
||||
if (seenPublicPaths?.has(e.localPath)) continue;
|
||||
const src = join(storeDir, e.storedFile);
|
||||
const from = fileExists(src) ? src : join(cssDir, e.storedFile);
|
||||
|
||||
@@ -34,6 +34,9 @@ export type IRNode = {
|
||||
sizingByVp?: Record<number, RawSizing>;
|
||||
beforeByVp?: Record<number, StyleMap>;
|
||||
afterByVp?: Record<number, StyleMap>;
|
||||
// ::placeholder computed style (input/textarea with placeholder text) — emitted as a
|
||||
// `::placeholder` rule so form controls keep their authored placeholder color.
|
||||
placeholderByVp?: Record<number, StyleMap>;
|
||||
children: IRChild[];
|
||||
};
|
||||
|
||||
@@ -156,10 +159,12 @@ function resolveLazyAttrs(attrs: Record<string, string> | undefined): Record<str
|
||||
return out;
|
||||
}
|
||||
|
||||
// `<iframe>` is intentionally NOT noise: it is kept as a sized placeholder box (its
|
||||
// external document is dropped at generation — see propsList — so the clone stays
|
||||
// self-contained while preserving the layout the embed occupied). Truly invisible
|
||||
// iframes (tracking/chat, 0-size or display:none) are removed by the visibility prune.
|
||||
// `<iframe>` is intentionally NOT noise: capture grafts an embedded document's subtree
|
||||
// as the iframe's children (capture/graft.ts) so form embeds (Klaviyo et al) render as
|
||||
// real content — generation then emits the iframe as a positioned <div>. When the graft
|
||||
// wasn't possible the iframe is kept as a sized placeholder box (document-loading attrs
|
||||
// dropped at generation — see propsList — so the clone stays self-contained). Truly
|
||||
// invisible iframes (tracking/chat, 0-size or display:none) are removed by the prune.
|
||||
const NOISE_TAGS = new Set(["next-route-announcer"]);
|
||||
|
||||
// Third-party overlay widgets (chat launchers, cookie/consent bars, captcha badges) are
|
||||
@@ -306,6 +311,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
const sizingByVp: Record<number, RawSizing> = {};
|
||||
const beforeByVp: Record<number, StyleMap> = {};
|
||||
const afterByVp: Record<number, StyleMap> = {};
|
||||
const placeholderByVp: Record<number, StyleMap> = {};
|
||||
|
||||
for (const vw of viewports) {
|
||||
const match = matched[vw];
|
||||
@@ -316,6 +322,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
if (match.sizing) sizingByVp[vw] = match.sizing;
|
||||
if (match.before) beforeByVp[vw] = match.before;
|
||||
if (match.after) afterByVp[vw] = match.after;
|
||||
if (match.placeholder) placeholderByVp[vw] = match.placeholder;
|
||||
}
|
||||
|
||||
const node: IRNode = {
|
||||
@@ -333,6 +340,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
||||
if (Object.keys(beforeByVp).length) node.beforeByVp = beforeByVp;
|
||||
if (Object.keys(afterByVp).length) node.afterByVp = afterByVp;
|
||||
if (Object.keys(placeholderByVp).length) node.placeholderByVp = placeholderByVp;
|
||||
|
||||
if (!raw.rawHTML) {
|
||||
const canonKids = elementChildren(raw);
|
||||
@@ -381,11 +389,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
keptChildren.push(c);
|
||||
continue;
|
||||
}
|
||||
// `<source>` carries a <picture>/<video>'s media/art-direction candidates but never
|
||||
// paints (0×0 at every viewport), so the visibility prune would always drop it —
|
||||
// losing responsive variants (the lazy-loaded mobile img.src then serves every
|
||||
// width). Keep it structurally; generation emits it only when its file materialized.
|
||||
const keepSource = c.tag === "source" && (node.tag === "picture" || node.tag === "video");
|
||||
if (keepAll) {
|
||||
prune(c, true);
|
||||
keptChildren.push(c);
|
||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||
} else if (prune(c, false)) {
|
||||
} else if (prune(c, false) || keepSource) {
|
||||
keptChildren.push(c);
|
||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||
}
|
||||
|
||||
@@ -438,6 +438,7 @@ export function generateSiteApp(opts: {
|
||||
const globals = tailwindGlobalsCss({
|
||||
reset: RESET_CSS, fontCss: unionFontCss(routes), tokensCss, htmlBg, bodyFont: SYSTEM_FALLBACK, clip,
|
||||
colorTokens: [...interner.tokens], viewports: entry.ir.doc.viewports,
|
||||
canonical: entry.ir.doc.canonicalViewport,
|
||||
});
|
||||
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user