Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes

Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
  capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
  markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
  generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
  (collapsed sections, viewport-inconsistent captures); settle autoplay
  carousels to home slide before every snapshot; force-reveal hidden
  videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
  picture>source through IR prune with srcset rewrite (mobile-crop-on-
  desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
  forms) with element-screenshot fallback; asset download retry +
  visual-assets-missing reporting

Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.

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