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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user