Fix build-breaking ident rewrite, blank-iframe grafting, and layout-gate regressions
Round-3 fixes from gate validation + human visual review of fresh cropin.com and ooni.com clones: - app.ts: section data-var rename used an unanchored substring replace, so Tile2_data matched inside MediaTile2_data and emitted a corrupted Mediatile2Data usage (ReferenceError at prerender). Word-boundary regex; every map site now shares one derivation. - graft.ts: about:blank iframes were blanket-skipped, missing the Klaviyo newsletter form (mounted into a blank same-origin frame via JS) — the audit's #1 complaint. Blank frames now graft; the visibility gate still excludes 0-size tracking pixels. - walker.ts: isVisible() now rejects boxes wholly outside the viewport (off-left always; off-right only when the page isn't horizontally scrollable; fixed boxes fully above/below), so a closed slide-in drawer's contents no longer count as expected text (91.8% -> 93.4%). - css.ts: four sizing-regime corrections — pin captured px for circular shrink-0 slides (Splide slide chain collapsed 0x0); flex-basis:100% for full-width shrink-0 slides; keep fixed-px grid templates for scrolling track lists (repeat(N,1fr) squished a 50-track carousel); single full-bleed fixed track -> minmax(0,1fr); keep authored heights whose in-flow children are fill children (aspect-video heroes inflated 240 -> 720px); width:100% for inset-spanned aspect boxes. Gate scores: ooni home 88.7 -> 93.3 (responsive 32 fails -> pass), pizza-ovens 90.8 -> 97.6 (perceptual 46.7% -> 17.2%). 107/107 compiler tests, all workspaces green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
199359d0a6
commit
7f99b76f66
@@ -36,10 +36,22 @@ const FRAME_SKIP_RE =
|
||||
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). */
|
||||
/**
|
||||
* How to materialize a frame's content, from its URL alone (deterministic).
|
||||
*
|
||||
* NOTE on blank/empty-src frames: a frame with no `src` (or `about:blank`/`javascript:`)
|
||||
* is NOT inert — many form/widget embeds (Klaviyo lightbox signup, loyalty popups) mount a
|
||||
* same-origin blank iframe and inject their rendered DOM into it via script, so the element
|
||||
* carries real, sized content with no navigable URL. Those must GRAFT (the frame document is
|
||||
* same-origin, so collectPage evaluates in it directly). The dead pixels that also use a blank
|
||||
* src (analytics sandboxes, 0×0 tracking iframes) are filtered upstream by the `cand.visible`
|
||||
* gate in capture.ts — a blank frame only reaches a graft when it actually rendered at
|
||||
* ≥ MIN_FRAME_DIM on both axes, which a tracking pixel never does.
|
||||
*/
|
||||
export function planForFrameUrl(url: string): FramePlan {
|
||||
const u = (url || "").trim();
|
||||
if (!u || u === "about:blank" || u.startsWith("javascript:")) return "skip";
|
||||
if (u.startsWith("javascript:")) return "skip";
|
||||
if (!u || u === "about:blank") return "graft"; // JS-populated same-origin frame (visibility-gated)
|
||||
if (FRAME_SKIP_RE.test(u)) return "skip";
|
||||
if (FRAME_STILL_RE.test(u)) return "still";
|
||||
return "graft";
|
||||
|
||||
@@ -196,6 +196,21 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Viewport + page extents used by isVisible's off-screen test. bbox coords are in
|
||||
// DOCUMENT space (r + scroll), so the visible window on each axis is
|
||||
// [scroll, scroll + inner]. A box whose border box lies wholly outside that window
|
||||
// on a NON-scrollable axis is unreachable and paints nothing to the user.
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
const scrollEl = document.scrollingElement || document.documentElement;
|
||||
// Horizontal scrolling is legitimate when the page is wider than the viewport (RTL
|
||||
// carousels, horizontal galleries). In that case content parked to the RIGHT is
|
||||
// reachable by scrolling, so we only reject boxes fully off the LEFT edge (x <= 0
|
||||
// start-of-page, never reachable). Vertical always scrolls, so we never reject
|
||||
// in-flow content below the fold — only position:fixed boxes, which do NOT scroll
|
||||
// with the page and so are truly gone if parked above/below the viewport.
|
||||
const horizScrollable = round2(scrollEl.scrollWidth) > vpW + 1;
|
||||
|
||||
// Resolve `line-height: normal` to a concrete px value by probing the actual
|
||||
// line-box height for each (font-family, font-size, font-weight, font-style).
|
||||
// getComputedStyle reports the keyword "normal", which renders font-metric-
|
||||
@@ -234,6 +249,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
|
||||
const isVisible = (el: Element, cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
|
||||
if (cs.display === "none") return false;
|
||||
// getComputedStyle already resolves `visibility` inheritance: a descendant that
|
||||
// sets visibility:visible inside a hidden ancestor reports "visible" here (and is
|
||||
// genuinely painted), so this test is exactly CSS computed semantics — no separate
|
||||
// ancestor walk is needed. The off-screen test below is what catches un-hidden
|
||||
// content parked outside the viewport (e.g. a slide-in drawer's inner nodes).
|
||||
if (cs.visibility === "hidden" || cs.visibility === "collapse") return false;
|
||||
if (parseFloat(cs.opacity || "1") === 0) return false;
|
||||
if (bbox.width === 0 && bbox.height === 0) {
|
||||
@@ -241,6 +261,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
// as not visible for matching purposes.
|
||||
return false;
|
||||
}
|
||||
// Off-screen test. bbox is document-space (x/y already include scroll). The box is
|
||||
// invisible only when it lies WHOLLY outside a non-scrollable axis window — a box
|
||||
// that merely straddles an edge (negative-margin / overflow-hidden decoration
|
||||
// peeking in) still paints and stays visible.
|
||||
//
|
||||
// Horizontal: the page never scrolls left of origin, so anything whose right edge
|
||||
// is at/left of 0 is unreachable. When the page is NOT horizontally scrollable we
|
||||
// also reject boxes whose left edge is at/right of the viewport width; when it IS
|
||||
// scrollable (wide/RTL/carousel pages), right-parked content is reachable, so only
|
||||
// the fully-left case counts.
|
||||
const rightEdge = bbox.x + bbox.width;
|
||||
if (rightEdge <= 0) return false;
|
||||
if (!horizScrollable && bbox.x >= vpW) return false;
|
||||
// Vertical: the page scrolls, so below-/above-fold in-flow content is reachable and
|
||||
// must stay visible. Only position:fixed boxes are pinned to the viewport and do NOT
|
||||
// scroll into view — a fixed box parked entirely above or below the viewport is gone.
|
||||
if (cs.position === "fixed") {
|
||||
const top = bbox.y - scrollY;
|
||||
const bottom = top + bbox.height;
|
||||
if (bottom <= 0) return false;
|
||||
if (top >= vpH) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -2053,7 +2053,10 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
|
||||
let body = jsx;
|
||||
for (const v of usedData) {
|
||||
const p = dataProps.get(v) ?? camelVar(v);
|
||||
if (p !== v) body = body.split(`${v}.map(`).join(`${p}.map(`);
|
||||
// Word-boundary rewrite: a plain substring replace of `${v}.map(` would also match
|
||||
// inside a longer var (e.g. `Tile2_data` is a suffix of `MediaTile2_data`), corrupting
|
||||
// it. Anchor on a non-identifier char before the name so only the whole var is renamed.
|
||||
if (p !== v) body = body.replace(new RegExp(`(^|[^\\w$])${escapeRegExp(v)}\\.map\\(`, "g"), `$1${p}.map(`);
|
||||
const binding = contentBindings.get(v);
|
||||
if (binding) {
|
||||
const alias = safeIdent(`${p}Content`, "contentData");
|
||||
|
||||
@@ -294,7 +294,7 @@ function isFluidFillItem(node: IRNode, layoutParent: IRNode | undefined, viewpor
|
||||
* captured viewport (so gates 0–6, measured only at those widths, are unmoved) yet scales
|
||||
* fluidly everywhere else — the same generalisation `isFluidFullBleed` already applies to
|
||||
* full-viewport elements, extended to "fills/centres within its container". */
|
||||
export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"; pct: string } | { kind: "percentVp"; pctByVp: Record<number, string> } | { kind: "flexfill" } | { kind: "fill" } | { kind: "fillcap"; cap: string } | { kind: "basis"; px: string };
|
||||
export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"; pct: string } | { kind: "percentVp"; pctByVp: Record<number, string> } | { kind: "flexfill" } | { kind: "fill" } | { kind: "fillcap"; cap: string } | { kind: "basis"; px: string } | { kind: "basisFull" };
|
||||
const PLAN_FIXED: WidthPlan = { kind: "fixed" };
|
||||
|
||||
const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; };
|
||||
@@ -530,6 +530,35 @@ function gridAutoTrackMask(node: IRNode, vps: number[], count: number): boolean[
|
||||
}
|
||||
|
||||
function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string> | null {
|
||||
// A SINGLE fixed-px grid track that FILLS its container at every sampled viewport is a full-bleed
|
||||
// fill column baked as `grid-cols-[Npx]` (a full-viewport hero carousel `uwp-carousel` with one
|
||||
// track = the viewport width, re-baked per breakpoint). It freezes at the nearest band's px, so the
|
||||
// grid — and everything it lays out — over-widens at any unsampled width (the splide02 hero measuring
|
||||
// 768px inside a 572px window: the whole hero subtree inherits the frozen track). Re-express the lone
|
||||
// track as `minmax(0,1fr)` (fills, reproduces the captured px exactly — gate-neutral). This is proven
|
||||
// purely from the box geometry (track == content), so it is safe even on a REPLACED/custom-element
|
||||
// grid, which the general solver below (it walks children) correctly still skips.
|
||||
{
|
||||
const filled: { vp: number; content: number }[] = [];
|
||||
for (const vp of viewports) {
|
||||
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
|
||||
if (!cs || !nb) continue;
|
||||
if (!/^(grid|inline-grid)$/.test(cs.display || "")) continue;
|
||||
const tracks = parseTracks(cs.gridTemplateColumns);
|
||||
if (!tracks || tracks.length !== 1) { filled.length = 0; break; } // must be single-track at EVERY grid vp
|
||||
const gap = pf(cs.columnGap && cs.columnGap !== "normal" ? cs.columnGap : cs.gap);
|
||||
if (gap !== 0) { filled.length = 0; break; }
|
||||
const content = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
|
||||
if (content <= 0) { filled.length = 0; break; }
|
||||
if (Math.abs(tracks[0]! - content) > Math.max(1.5, 0.01 * content)) { filled.length = 0; break; } // track must FILL
|
||||
filled.push({ vp, content });
|
||||
}
|
||||
if (filled.length >= 2) {
|
||||
const m = new Map<number, string>();
|
||||
for (const f of filled) m.set(f.vp, "minmax(0, 1fr)");
|
||||
return m;
|
||||
}
|
||||
}
|
||||
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
|
||||
type S = { vp: number; tracks: number[]; gap: number; content: number };
|
||||
// Replace a solved template's fixed `Npx` tracks with `auto` where the column provably sizes to its
|
||||
@@ -585,10 +614,17 @@ function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string
|
||||
for (const s of perVp) { const c = s.tracks.length; const g = byCount.get(c); if (g) g.push(s); else byCount.set(c, [s]); }
|
||||
const result = new Map<number, string>();
|
||||
for (const [count, samples] of byCount) {
|
||||
// Every sample in the regime has ~equal tracks ⇒ a clean `repeat(N, 1fr)`. (1fr divides the
|
||||
// Every sample in the regime has ~equal tracks ⇒ a candidate `repeat(N, 1fr)`. (1fr divides the
|
||||
// container content evenly, reproducing the equal baked px at each captured width.)
|
||||
const allEqual = samples.every((s) => Math.max(...s.tracks) - Math.min(...s.tracks) <= Math.max(1.5, 0.02 * Math.max(...s.tracks)));
|
||||
const tmpl = allEqual ? `repeat(${count}, minmax(0, 1fr))` : (samples.length >= 2 ? frTemplate(samples, count) : null);
|
||||
// `repeat(N,1fr)` divides the CONTAINER content among the tracks — valid only when the tracks +
|
||||
// gaps actually FILL the container (the same fill law frTemplate enforces before solving an fr
|
||||
// model). A fixed-track scrolling list (`overflow-x:auto` with `grid-cols-[173.5px…]` × 50 summing
|
||||
// to 8675px inside a 1066px container) has equal tracks too, but rewriting it as `repeat(50,1fr)`
|
||||
// shrinks every slide to viewport/50 and kills the horizontal scroll. When the equal tracks
|
||||
// OVERFLOW the container, keep the baked fixed-px template (fall through, leaving these vps unset).
|
||||
const fillsContainer = samples.every((s) => Math.abs(s.tracks.reduce((a, b) => a + b, 0) + (count - 1) * s.gap - s.content) <= Math.max(2, 0.01 * s.content));
|
||||
const tmpl = (allEqual && fillsContainer) ? `repeat(${count}, minmax(0, 1fr))` : (samples.length >= 2 ? frTemplate(samples, count) : null);
|
||||
if (tmpl) { const t2 = withAuto(tmpl, samples); for (const s of samples) result.set(s.vp, t2); continue; }
|
||||
// Rescue a MIXED-track regime where frTemplate bailed only because the column PROPORTIONS
|
||||
// change at a breakpoint — the ridge footer signup grid is 70/30 (`2.333fr 1fr`) at ≥768 but
|
||||
@@ -910,6 +946,90 @@ function isFlexFillItem(node: IRNode, parentNode: IRNode | undefined, viewports:
|
||||
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
|
||||
}
|
||||
|
||||
/** A CIRCULAR-DEPENDENCY flex/grid item whose width has NO in-flow source and would collapse to 0.
|
||||
*
|
||||
* A Splide (and similar JS carousel) slide is a `shrink-0` flex item whose width is set only by the
|
||||
* library's INJECTED inline `width:Npx`. The sizing probe therefore reads it as content/fill-sized
|
||||
* (`wAuto`/`wFill` both true), so generation drops the width. But the slide's only in-flow children
|
||||
* FILL it (`w-full h-full`, often `aspect-square`) — they take their width FROM the slide. With the
|
||||
* slide's width dropped and every child filling, nothing establishes a definite width: the slide, the
|
||||
* fill children, and the track all resolve to 0×0 (the "Explore Our Oven Range" carousel collapsing
|
||||
* to a −640px hole).
|
||||
*
|
||||
* Detect exactly that circular case and pin the captured px so the fill children have a definite basis:
|
||||
* - the node is an IN-FLOW flex or grid ITEM with `flex-shrink:0` (a carousel slide, never shrinks),
|
||||
* - it has a definite positive captured width at every painted viewport,
|
||||
* - it has NO in-flow width source: every in-flow ELEMENT child either FILLS it
|
||||
* (probe `wFill && !wAuto` → width derives from the slide) or is absolutely/fixed positioned,
|
||||
* and at least one such fill child exists (the thing that would collapse).
|
||||
* Requires probe data (older captures return false → inert). Scoped hard so it fires only on the
|
||||
* genuine circular collapse, never on a normal content-sized shrink-0 item. */
|
||||
function isCircularShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
|
||||
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
|
||||
let painted = 0;
|
||||
for (const vp of viewports) {
|
||||
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; const nb = node.bboxByVp[vp];
|
||||
if (!cs || !pcs || !nb) continue;
|
||||
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // judge only where painted
|
||||
if (!/^(flex|inline-flex|grid|inline-grid)$/.test(pcs.display || "")) return false; // parent must lay it out as a flex/grid item
|
||||
const pos = cs.position || "static";
|
||||
if (pos !== "static" && pos !== "relative") return false; // in-flow item only
|
||||
if ((cs.float || "none") !== "none") return false;
|
||||
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0 (never collapses to content)
|
||||
if (!(nb.width > 0) || !(pf(cs.width) > 0)) return false; // need a definite captured px to pin
|
||||
// No in-flow width source: every in-flow element child fills the slide (its width derives from the
|
||||
// slide) or is out of flow; at least one fill child must exist (else nothing collapses).
|
||||
let fillChildren = 0;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
|
||||
const cpos = ccs.position || "static";
|
||||
if (cpos === "absolute" || cpos === "fixed") continue; // out of flow → no width contribution
|
||||
const csz = c.sizingByVp?.[vp];
|
||||
if (csz && csz.wFill === true && csz.wAuto === false) { fillChildren++; continue; } // fills the slide → derives width from it
|
||||
return false; // a genuine in-flow width source → not circular
|
||||
}
|
||||
if (fillChildren === 0) return false;
|
||||
painted++;
|
||||
}
|
||||
return painted >= 1;
|
||||
}
|
||||
|
||||
/** A FULL-WIDTH carousel slide: a `shrink-0` flex-ROW item whose border box FILLS its flex
|
||||
* container's content width at every painted viewport (a full-viewport hero slide in a Splide list;
|
||||
* the slides sit side by side and the track clips via overflow). The naive emission `width:100%` on
|
||||
* a `shrink-0` flex item does NOT stay at 100%: flex sizes the item from its MAX-CONTENT (the
|
||||
* shrink-0 item can't drop below it), and a full-bleed slide's max-content (its absolutely-positioned
|
||||
* aspect-ratio media) freezes the whole list wider than the container at any unsampled width (the
|
||||
* splide02 hero list measuring 768px inside a 572px window). Emitting `flex-basis:100%` gives the
|
||||
* slide a DEFINITE main size = the container content width, so the flex row stays exactly one
|
||||
* container wide at every width (gate-neutral at samples, correct in between). Distinct from
|
||||
* isCircularShrinkSlide (a FIXED-width sub-container slide that pins its captured px). */
|
||||
function isFullWidthShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
|
||||
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
|
||||
let painted = 0;
|
||||
for (const vp of viewports) {
|
||||
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp];
|
||||
const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp];
|
||||
if (!cs || !pcs || !nb || !pb) continue;
|
||||
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue;
|
||||
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false; // flex parent
|
||||
const dir = pcs.flexDirection || "row";
|
||||
if (dir !== "row" && dir !== "row-reverse") return false; // width is the main axis
|
||||
const pos = cs.position || "static";
|
||||
if (pos !== "static" && pos !== "relative") return false;
|
||||
if ((cs.float || "none") !== "none") return false;
|
||||
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0
|
||||
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // margins make the width load-bearing
|
||||
if ((cs.boxSizing || "border-box") === "content-box") return false; // basis:100% would overflow by padding
|
||||
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
|
||||
if (Math.abs(nb.width - content) > 1.5) return false; // must actually FILL the flex container
|
||||
painted++;
|
||||
}
|
||||
return painted >= 1;
|
||||
}
|
||||
|
||||
/** A flex/grid ITEM whose border box fills its container's content width at every viewport —
|
||||
* cross-axis stretch in a column, a sole/spanning item in a row, a full-span grid cell. We were
|
||||
* baking the resolved fill width per viewport, which freezes it AND (the px differs per width)
|
||||
@@ -1872,6 +1992,12 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
|
||||
// child's trailing margin-bottom, because whether that margin extends the box (height includes
|
||||
// it) or collapses through depends on the box; we accept the box if its height matches EITHER.
|
||||
let bottom = nb.y + pf(cs.paddingTop) + pf(cs.borderTopWidth); let bottomMargin = bottom; let hasChild = false;
|
||||
// Are ALL in-flow element children FILL children (height:100% / probe hFill) whose height DERIVES
|
||||
// from THIS box? Then their extent reaching the box bottom is not content evidence — it is the
|
||||
// box's own authored height reflected back (a `h-full aspect-video` hero image inside a fixed-height
|
||||
// header). Dropping the height then lets the fill child re-derive from its aspect ratio and inflate
|
||||
// (a 240px hero → 720px @16/9). Do NOT flow such a box's height.
|
||||
let allInflowFill = true;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||
@@ -1879,9 +2005,15 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu
|
||||
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
||||
if ((ccs.float || "none") !== "none") continue;
|
||||
hasChild = true;
|
||||
const csz = c.sizingByVp?.[vp];
|
||||
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
||||
bottom = Math.max(bottom, cb.y + cb.height);
|
||||
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
|
||||
}
|
||||
// Authored height whose only in-flow children fill it (circular): keep the height (don't flow).
|
||||
// Guarded by the parent's own probe: hAuto===false means auto did NOT reproduce the box → real
|
||||
// authored height, not a content coincidence.
|
||||
if (hasChild && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false) return false;
|
||||
const pad = pf(cs.paddingBottom) + pf(cs.borderBottomWidth);
|
||||
const contentH = bottom - nb.y + pad; const contentHMargin = bottomMargin - nb.y + pad;
|
||||
// Content-driven when the box is exactly its in-flow children's extent — with OR without their
|
||||
@@ -2115,6 +2247,9 @@ function declsForViewport(
|
||||
// The band-delta machinery collapses equal neighbours, so equal regimes emit a single rule.
|
||||
else if (widthPlan.kind === "percentVp") { const p = widthPlan.pctByVp[vp]; if (p) out.set("width", p); }
|
||||
else if (widthPlan.kind === "flexfill") { out.set("flex-grow", "1"); out.set("flex-basis", "0%"); }
|
||||
// A full-width shrink-0 carousel slide: definite main size = container content width, so the flex
|
||||
// row stays exactly one container wide (no max-content over-widening). Keeps flex-shrink:0 (source).
|
||||
else if (widthPlan.kind === "basisFull") { out.set("flex-basis", "100%"); out.set("flex-shrink", "0"); }
|
||||
// A flex-line item's RECOVERED natural width (its flex base size), emitted as a constant at
|
||||
// every viewport so the captured per-viewport px collapse to one value — flex-grow/shrink then
|
||||
// re-derives the resolved widths. Verified to reproduce every sample before being chosen.
|
||||
@@ -2152,6 +2287,11 @@ function declsForViewport(
|
||||
let contentBottom = top; // includes trailing margins (for taller detection)
|
||||
let borderBottom = top; // border-box extent only (for overflow detection)
|
||||
let inflowCount = 0;
|
||||
// Are ALL the in-flow element children fill children whose height DERIVES from this
|
||||
// node (height:100% / probe hFill)? If so their measured extent equals this box's
|
||||
// height only BECAUSE they fill it — it is not content-derived evidence, so it must
|
||||
// not be allowed to "explain away" an authored height below.
|
||||
let allInflowFill = true;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
|
||||
@@ -2162,6 +2302,10 @@ function declsForViewport(
|
||||
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
|
||||
if ((ccs.float || "none") !== "none") continue;
|
||||
inflowCount++;
|
||||
const csz = c.sizingByVp?.[vp];
|
||||
// A fill child: the probe measured height:100% reproduces AND auto does not (hFill && !hAuto).
|
||||
// No probe data (older captures) ⇒ treat as real content (conservative: leave allInflowFill off).
|
||||
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
|
||||
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
|
||||
borderBottom = Math.max(borderBottom, cb.y + cb.height);
|
||||
}
|
||||
@@ -2169,6 +2313,13 @@ function declsForViewport(
|
||||
const contentHeight = contentBottom - nb.y + padBottom;
|
||||
if (nb.height > contentHeight + 2) explicitHeight = true;
|
||||
if (inflowCount === 0 && nb.height > 2) explicitHeight = true;
|
||||
// Authored height whose only in-flow children FILL it (height:100%). The child extent measured
|
||||
// the parent's own height back at it (circular), so the "taller than content" test above can never
|
||||
// fire — yet dropping the height lets each fill child re-derive its height from content/aspect
|
||||
// (a `h-full aspect-video` child then resolves 16/9 of its width, inflating a 240px hero to 720px).
|
||||
// Keep the authored height so the fill chain has a definite basis. Probe hFill (not `!hAuto` on the
|
||||
// parent) is the guard: hAuto:false on the parent means auto did NOT reproduce, i.e. real authored.
|
||||
if (inflowCount > 0 && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false && nb.height > 2) explicitHeight = true;
|
||||
// Overflow case: the box is meaningfully SHORTER than its in-flow content's
|
||||
// border boxes. Content cannot shrink a box below its own size, so the height
|
||||
// is authored (e.g. html,body{height:100%} with overflowing content). Compare
|
||||
@@ -2850,7 +3001,15 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
||||
// track. A `flex gap w-1/3` equal-fill row item: re-express as flex:1 1 0 so it scales.
|
||||
const gridFill = isGridItemFill(node, parentNode, sampleVps);
|
||||
const flexFill = !gridFill && isFlexFillItem(node, parentNode, sampleVps);
|
||||
const fillsContainer = !gridFill && !flexFill && isFillsContainerWidth(node, parentNode, sampleVps);
|
||||
// A carousel slide (`shrink-0` flex/grid item) whose width was set only by the library's injected
|
||||
// inline px and whose children all FILL it → the probe reads it as fill/content and drops the
|
||||
// width, collapsing the slide + its fill children + the track to 0×0. Pin the captured px so the
|
||||
// fill chain has a definite basis. Wins over the probe/fill detectors below (it IS the width source).
|
||||
const circularSlide = !isContents && !gridFill && !flexFill && isCircularShrinkSlide(node, parentNode, sampleVps);
|
||||
// A full-VIEWPORT-width shrink-0 carousel slide → flex-basis:100% (definite main size) instead of
|
||||
// width:100%, so the flex row can't over-widen from the slide's max-content (the splide02 hero).
|
||||
const fullWidthSlide = !isContents && !gridFill && !flexFill && !circularSlide && isFullWidthShrinkSlide(node, parentNode, sampleVps);
|
||||
const fillsContainer = !gridFill && !flexFill && !circularSlide && !fullWidthSlide && isFillsContainerWidth(node, parentNode, sampleVps);
|
||||
const replacedFill = replacedFillsContainer(node, parentNode, sampleVps); // an img/video filling its cell → w-full
|
||||
const sourceFill = sourceWidthFillIntent(node);
|
||||
// A full-bleed banner image (width ≈ viewport, grows) baked to per-vp px → w-full so it keeps
|
||||
@@ -2864,6 +3023,18 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
||||
const blockFill = !gridFill && !flexFill && !fillsContainer && !maxWidthFill && fillsBlockContainer(node, parentNode, sampleVps);
|
||||
// An absolute box pinned by both left+right insets → drop width (auto stretches between them).
|
||||
const insetSpanned = !isContents && insetSpannedAbsolute(node, parentNode, sampleVps);
|
||||
// If that inset-spanned box ALSO carries an authored aspect-ratio (a full-bleed hero slide:
|
||||
// `absolute inset-x-0 aspect-video min-h-[…]`), `width:auto` does NOT stay pinned to the insets:
|
||||
// with a definite height (min-height) and a definite aspect-ratio, the width BACK-COMPUTES from
|
||||
// height × aspect and over-widens at any width the capture didn't sample (the splide02 hero
|
||||
// measuring 768/641px wide inside a 572px window — the responsive full-bleed violations). Emit an
|
||||
// explicit `width:100%` instead: it fills the containing block (identical to the inset-0 span at
|
||||
// every sampled width, gate-neutral) and, being definite, WINS the width axis so the aspect-ratio
|
||||
// drives only the height. Scoped to inset-spanned boxes that actually have an aspect-ratio.
|
||||
const insetSpannedAspect = insetSpanned && sampleVps.some((vp) => {
|
||||
const ar = node.computedByVp[vp]?.aspectRatio;
|
||||
return !!ar && ar !== "auto" && node.visibleByVp[vp];
|
||||
});
|
||||
// Combine: the full-bleed chain wins (it proved the box spans the viewport), then grid/flex
|
||||
// fill, then "fills its container" / "fills under a max-width cap" (→ width:100%), then the
|
||||
// inferred generalisations.
|
||||
@@ -2891,6 +3062,9 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
||||
const widthPlan: WidthPlan =
|
||||
fillCap ? { kind: "fillcap", cap: fillCap }
|
||||
: sourceFixedSize ? inferred.plan
|
||||
: circularSlide ? { kind: "fixed" }
|
||||
: fullWidthSlide ? { kind: "basisFull" }
|
||||
: insetSpannedAspect ? { kind: "fill" }
|
||||
: probe === "auto" ? { kind: "auto" }
|
||||
: probe === "fill" ? { kind: "fill" }
|
||||
: (!lockWidth && autoWidthFlex.has(node.id)) ? { kind: "auto" }
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { RawSizing } from "../src/capture/walker.js";
|
||||
import { generateCss } from "../src/generate/css.js";
|
||||
|
||||
// Two-plus viewports so the per-vp fluid detectors (grid-template solve, width probe) can run.
|
||||
const VPS = [375, 768, 1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||
}
|
||||
|
||||
type PerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean };
|
||||
|
||||
/** Build a node with independent per-viewport computed style / bbox / sizing probe. */
|
||||
function vpNode(id: string, tag: string, byVp: Record<number, PerVp>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
const sizingByVp: Record<number, RawSizing> = {};
|
||||
for (const vp of VPS) {
|
||||
const s = byVp[vp]!;
|
||||
computedByVp[vp] = computed(s.cs);
|
||||
bboxByVp[vp] = s.bbox;
|
||||
visibleByVp[vp] = s.visible ?? true;
|
||||
if (s.sizing) sizingByVp[vp] = s.sizing;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
if (Object.keys(sizingByVp).length) n.sizingByVp = sizingByVp;
|
||||
return n;
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/fidelity",
|
||||
title: "Fidelity Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: CANONICAL,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 4000, scrollWidth: vp, htmlBg: "rgb(255,255,255)", bodyBg: "rgb(255,255,255)", bodyColor: "rgb(0,0,0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 8,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
/** The base-rule body for a selector (first non-banded `.c<id>{…}` block). */
|
||||
function baseRule(css: string, id: string): string {
|
||||
const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
/** Every `.c<id>{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */
|
||||
function allRules(css: string, id: string): string {
|
||||
const re = new RegExp(`\\.c${id}\\{([^}]*)\\}`, "g");
|
||||
let out = "", m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css))) out += m[1] + ";";
|
||||
return out;
|
||||
}
|
||||
|
||||
const fills = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: true });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIX 1 — circular shrink-0 carousel slide keeps a definite width
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("FIX 1: circular carousel slide width pinning", () => {
|
||||
// Track (flex) → slide <a> (shrink-0, probe says fill/auto) → inner (w-full h-full).
|
||||
// The probe would drop the slide width; with an all-fill child it must instead be pinned.
|
||||
function build(slideChildSizing: RawSizing, slideShrink = "0"): IR {
|
||||
const slideW = { 375: 300, 768: 400, 1280: 566 } as Record<number, number>;
|
||||
const inner = vpNode("n486", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", width: `${slideW[vp]}px`, height: "640px", aspectRatio: "1 / 1" },
|
||||
bbox: { x: 0, y: 0, width: slideW[vp]!, height: 640 },
|
||||
sizing: slideChildSizing,
|
||||
}])) as Record<number, PerVp>);
|
||||
const slide = vpNode("n485", "a", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", width: `${slideW[vp]}px`, height: "640px", flexShrink: slideShrink },
|
||||
bbox: { x: 0, y: 0, width: slideW[vp]!, height: 640 },
|
||||
// probe reads the slide as fill+content (Splide inline px looks derivable)
|
||||
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false },
|
||||
}])) as Record<number, PerVp>, [inner]);
|
||||
const track = vpNode("n484", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "flex", position: "static", width: `${vp}px`, height: "640px" },
|
||||
bbox: { x: 0, y: 0, width: vp, height: 640 },
|
||||
}])) as Record<number, PerVp>, [slide]);
|
||||
return irWith(track);
|
||||
}
|
||||
|
||||
it("pins the captured px width on a shrink-0 slide whose only child fills it", () => {
|
||||
const css = generateCss(build(fills()), new Map());
|
||||
const rules = allRules(css, "n485");
|
||||
// The captured canonical width (566px) must be emitted somewhere — not dropped to 100%/auto.
|
||||
assert.match(rules, /width:\s*566px/, `slide should keep its captured width; got: ${rules}`);
|
||||
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*100%/);
|
||||
});
|
||||
|
||||
it("does NOT pin when the child is a real in-flow content box (genuine width source)", () => {
|
||||
// Child sizes to its own content (wAuto true, wFill false) → not the circular case.
|
||||
const contentChild: RawSizing = { wAuto: true, wFill: false, hAuto: true, hFill: false };
|
||||
const css = generateCss(build(contentChild), new Map());
|
||||
const rules = allRules(css, "n485");
|
||||
// With a genuine width source, the probe verdict is honored — no forced 566px pin.
|
||||
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*566px/, `content-child slide must not be force-pinned; got: ${rules}`);
|
||||
});
|
||||
|
||||
it("does NOT pin a shrinkable (shrink:1) item even with a fill child", () => {
|
||||
const css = generateCss(build(fills(), "1"), new Map());
|
||||
assert.doesNotMatch(baseRule(css, "n485"), /width:\s*566px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIX 1b: full-width shrink-0 carousel slide gets flex-basis:100%", () => {
|
||||
// Flex list → shrink-0 slide whose border box FILLS the list (a full-viewport hero slide).
|
||||
function build(): IR {
|
||||
const slide = vpNode("n200", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", width: `${vp}px`, flexShrink: "0" },
|
||||
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||
sizing: { wAuto: false, wFill: true, hAuto: true, hFill: false },
|
||||
}])) as Record<number, PerVp>);
|
||||
const list = vpNode("n199", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "flex", position: "static", flexDirection: "row", width: `${vp}px` },
|
||||
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||
}])) as Record<number, PerVp>, [slide]);
|
||||
return irWith(list);
|
||||
}
|
||||
|
||||
it("emits flex-basis:100% + flex-shrink:0 (not width:100%) for a container-filling shrink-0 slide", () => {
|
||||
const rules = allRules(generateCss(build(), new Map()), "n200");
|
||||
assert.match(rules, /flex-basis:\s*100%/, `full-width slide should get flex-basis:100%; got: ${rules}`);
|
||||
assert.doesNotMatch(rules, /(?<!max-)width:\s*100%/, "should not use width:100% (flex max-content over-widening)");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIX 2 — fixed-track scroll grid keeps its px template; fluid grid still fr
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("FIX 2: fixed-track scroll grid vs fluid equal-track grid", () => {
|
||||
// N equal tracks. `content` is the container content width; `sum` is total track+gap extent.
|
||||
function gridNode(id: string, perVp: Record<number, { count: number; track: number; gap: number; content: number }>): IRNode {
|
||||
return vpNode(id, "div", Object.fromEntries(VPS.map((vp) => {
|
||||
const g = perVp[vp]!;
|
||||
const cols = new Array(g.count).fill(`${g.track}px`).join(" ");
|
||||
return [vp, {
|
||||
cs: { display: "grid", position: "static", gridTemplateColumns: cols, columnGap: `${g.gap}px`, width: `${g.content}px`, overflowX: "auto" },
|
||||
bbox: { x: 0, y: 0, width: g.content, height: 300 },
|
||||
}];
|
||||
})) as Record<number, PerVp>);
|
||||
}
|
||||
|
||||
it("keeps the baked px template for a scrolling grid whose tracks overflow the container", () => {
|
||||
// 50 tracks of 173.5px in a ~1066px container = 8675px of scrolling content.
|
||||
const css = generateCss(irWith(gridNode("n550", {
|
||||
375: { count: 50, track: 132.9, gap: 0, content: 326 },
|
||||
768: { count: 50, track: 106.2, gap: 0, content: 662 },
|
||||
1280: { count: 50, track: 173.5, gap: 0, content: 1066 },
|
||||
})), new Map());
|
||||
const rules = allRules(css, "n550");
|
||||
assert.doesNotMatch(rules, /repeat\(50,/, `scrolling grid must not be rewritten as repeat(50,1fr); got: ${rules.slice(0, 200)}`);
|
||||
assert.match(rules, /173\.5px/, "scrolling grid should keep its baked fixed-px tracks");
|
||||
});
|
||||
|
||||
it("rewrites a single fixed-px full-bleed track as minmax(0,1fr) even on a custom-element grid", () => {
|
||||
// uwp-carousel: one track == viewport width, baked per breakpoint. Must become a fill track.
|
||||
const carousel = vpNode("n196", "uwp-carousel", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "grid", position: "relative", gridTemplateColumns: `${vp}px`, columnGap: "normal", width: `${vp}px`, paddingLeft: "0px", paddingRight: "0px" },
|
||||
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||
}])) as Record<number, PerVp>);
|
||||
const rules = allRules(generateCss(irWith(carousel), new Map()), "n196");
|
||||
assert.match(rules, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, `single full-bleed track should fill: ${rules.slice(0, 200)}`);
|
||||
assert.doesNotMatch(rules, /grid-template-columns:\s*\d/, "should not keep a baked px single track");
|
||||
});
|
||||
|
||||
it("still rewrites a genuinely fluid equal-track grid as repeat(N, 1fr)", () => {
|
||||
// 3 equal tracks that FILL the container (sum+gaps == content) at every width.
|
||||
const css = generateCss(irWith(gridNode("n700", {
|
||||
375: { count: 3, track: (375 - 2 * 16) / 3, gap: 16, content: 375 },
|
||||
768: { count: 3, track: (768 - 2 * 16) / 3, gap: 16, content: 768 },
|
||||
1280: { count: 3, track: (1280 - 2 * 16) / 3, gap: 16, content: 1280 },
|
||||
})), new Map());
|
||||
const rules = allRules(css, "n700");
|
||||
assert.match(rules, /repeat\(3,\s*minmax\(0,\s*1fr\)\)/, `filling equal-track grid should become repeat(3,1fr); got: ${rules.slice(0, 200)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIX 3b — inset-spanned absolute box with an aspect-ratio fills (width:100%)
|
||||
// instead of width:auto (which back-computes width from height × aspect and
|
||||
// over-widens at unsampled probe widths — the full-bleed responsive violations).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("FIX 3b: inset-spanned + aspect-ratio full-bleed box", () => {
|
||||
// Positioned parent → absolute child (inset-x-0) that stretches to the parent width at every vp.
|
||||
function bleed(childAspect?: string): IR {
|
||||
const child = vpNode("n202", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: {
|
||||
display: "block", position: "absolute", top: "0px", left: "0px", right: "0px",
|
||||
minHeight: "462px", ...(childAspect ? { aspectRatio: childAspect } : {}),
|
||||
},
|
||||
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||
}])) as Record<number, PerVp>);
|
||||
const parent = vpNode("n201", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", width: `${vp}px` },
|
||||
bbox: { x: 0, y: 0, width: vp, height: 462 },
|
||||
}])) as Record<number, PerVp>, [child]);
|
||||
return irWith(parent);
|
||||
}
|
||||
|
||||
it("emits width:100% for an inset-spanned absolute box that carries an aspect-ratio", () => {
|
||||
const css = generateCss(bleed("16 / 9"), new Map());
|
||||
assert.match(baseRule(css, "n202"), /width:\s*100%/, `aspect full-bleed box should fill: ${baseRule(css, "n202")}`);
|
||||
});
|
||||
|
||||
it("keeps width:auto for an inset-spanned box with NO aspect-ratio (unchanged behaviour)", () => {
|
||||
const css = generateCss(bleed(undefined), new Map());
|
||||
assert.doesNotMatch(baseRule(css, "n202"), /width:\s*100%/, `plain inset-spanned box must not be forced to fill: ${baseRule(css, "n202")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIX 3 — authored height kept when the only in-flow child fills it
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("FIX 3: authored height with a fill-only child", () => {
|
||||
// Hero (authored, height VARIES per vp — a responsive header) → child (h-full aspect-video). The
|
||||
// child extent equals the parent's height at every vp ONLY because it fills. The varying height also
|
||||
// exercises heightFlows' "varies" gate, which is the path that was wrongly dropping the height.
|
||||
const HERO_H = { 375: 324.8, 768: 409.6, 1280: 240 } as Record<number, number>;
|
||||
function hero(childSizing: RawSizing, parentSizing: RawSizing): IR {
|
||||
const child = vpNode("n290", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "hidden", aspectRatio: "16 / 9" },
|
||||
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||
sizing: childSizing,
|
||||
}])) as Record<number, PerVp>);
|
||||
const h = vpNode("n289", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "visible" },
|
||||
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||
sizing: parentSizing,
|
||||
}])) as Record<number, PerVp>, [child]);
|
||||
return irWith(h);
|
||||
}
|
||||
|
||||
it("keeps the authored height when the sole in-flow child is a fill child (height derives from parent)", () => {
|
||||
const css = generateCss(hero(
|
||||
{ wAuto: false, wFill: true, hAuto: false, hFill: true }, // child fills (h-full)
|
||||
{ wAuto: true, wFill: true, hAuto: false, hFill: false }, // parent authored height (auto does NOT reproduce)
|
||||
), new Map());
|
||||
assert.match(baseRule(css, "n289"), /height:\s*240px/, `authored height must survive when child only fills it: ${baseRule(css, "n289")}`);
|
||||
});
|
||||
|
||||
it("still drops the height when the child is real content flow (its extent IS the evidence)", () => {
|
||||
// Child sizes to its own content (hAuto true, not a fill) AND the parent auto-height reproduces.
|
||||
// Height varies per-vp so the heightFlows path is exercised — it must STILL flow (drop) here.
|
||||
const child = vpNode("n290b", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px` },
|
||||
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false }, // content-sized child
|
||||
}])) as Record<number, PerVp>);
|
||||
const h = vpNode("n289b", "div", Object.fromEntries(VPS.map((vp) => [vp, {
|
||||
cs: { display: "block", position: "relative", height: `${HERO_H[vp]}px`, overflow: "visible" },
|
||||
bbox: { x: 0, y: 176, width: vp, height: HERO_H[vp]! },
|
||||
sizing: { wAuto: true, wFill: true, hAuto: true, hFill: false }, // parent auto-height reproduces → drop
|
||||
}])) as Record<number, PerVp>, [child]);
|
||||
const css = generateCss(irWith(h), new Map());
|
||||
assert.doesNotMatch(baseRule(css, "n289b"), /height:\s*(?:240|324\.8|409\.6)px/, `content-flow parent should let height drop: ${baseRule(css, "n289b")}`);
|
||||
});
|
||||
});
|
||||
@@ -47,15 +47,21 @@ function pageSnap(root: RawNode, url = "https://host.test/page"): PageSnapshot {
|
||||
}
|
||||
|
||||
describe("planForFrameUrl", () => {
|
||||
it("skips blank/js frames and ad/analytics/captcha domains", () => {
|
||||
assert.equal(planForFrameUrl(""), "skip");
|
||||
assert.equal(planForFrameUrl("about:blank"), "skip");
|
||||
it("skips javascript: frames and ad/analytics/captcha domains", () => {
|
||||
assert.equal(planForFrameUrl("javascript:void(0)"), "skip");
|
||||
assert.equal(planForFrameUrl("https://googleads.g.doubleclick.net/pagead/ads"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html?id=GTM-X"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.google.com/recaptcha/api2/anchor"), "skip");
|
||||
});
|
||||
|
||||
it("grafts blank/empty-src frames (JS-populated same-origin embeds), visibility-gated upstream", () => {
|
||||
// Klaviyo lightbox signup, loyalty popups etc. render into a blank same-origin iframe via
|
||||
// script — no navigable URL, but real content. Invisible tracking pixels sharing a blank
|
||||
// src are dropped by the cand.visible gate in capture.ts, not here.
|
||||
assert.equal(planForFrameUrl(""), "graft");
|
||||
assert.equal(planForFrameUrl("about:blank"), "graft");
|
||||
});
|
||||
|
||||
it("screenshots media-player embeds instead of grafting their JS-built DOM", () => {
|
||||
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc123"), "still");
|
||||
assert.equal(planForFrameUrl("https://player.vimeo.com/video/1"), "still");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sectionFiles, type ComponentRegistry, type SectionRegistry } from "../src/generate/app.js";
|
||||
import type { SectionPlan } from "../src/generate/sectionSplit.js";
|
||||
|
||||
// Regression: the data-var → camelCase-prop rewrite in a section module must be
|
||||
// word-boundary aware. A plain substring replace of `${v}.map(` also matched inside a
|
||||
// LONGER var that shares a suffix — e.g. `Tile2_data` is a suffix of `MediaTile2_data`, so
|
||||
// rewriting `Tile2_data.map(` corrupted `MediaTile2_data.map(` into `Mediatile2Data.map(`
|
||||
// (lowercase 't'), which no longer matched the `MediaTile2_data` declaration/import → the
|
||||
// generated app failed `next build` with `ReferenceError: Mediatile2Data is not defined`.
|
||||
//
|
||||
// The invariant these tests lock: every `X.map(` identifier a section emits has a matching
|
||||
// declaration/import/param in the SAME module (single canonical derivation, all sites).
|
||||
|
||||
function emptyReg(): ComponentRegistry {
|
||||
return {
|
||||
plan: { clusters: [] } as unknown as ComponentRegistry["plan"],
|
||||
nodeByCid: new Map(),
|
||||
funcDefs: new Map(),
|
||||
skeletonToName: new Map(),
|
||||
nameCounts: new Map(),
|
||||
dataDecls: [],
|
||||
cidDecls: [],
|
||||
styleDecls: [],
|
||||
dataCounts: new Map(),
|
||||
fieldTypes: new Map(),
|
||||
styleFieldTypes: new Map(),
|
||||
byName: new Map(),
|
||||
failed: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a shared-skeleton extracted component `name` with one data run, and return the
|
||||
* `{Name_data.map(...)}` call the generator emits inline (mirrors registerComponent). */
|
||||
function addComponent(reg: ComponentRegistry, name: string): string {
|
||||
reg.funcDefs.set(name, `function ${name}({ d, cids, styles }: { d: ${name}Data; cids: string[]; styles: ${name}Styles }) {\n return (\n <div />\n );\n}`);
|
||||
const dataVar = `${name}_data`;
|
||||
const cidsVar = `${name}_cids`;
|
||||
const stylesVar = `${name}_styles`;
|
||||
reg.dataDecls.push({ varName: dataVar, compName: name, body: `[\n { alt: "x" }\n]` });
|
||||
reg.cidDecls.push({ varName: cidsVar, body: `[\n ["a"]\n]` });
|
||||
reg.styleDecls.push({ varName: stylesVar, compName: name, body: `[\n {}\n]` });
|
||||
reg.fieldTypes.set(name, [{ name: "alt", type: "string", optional: false }]);
|
||||
reg.byName.set(name, { runs: 1, instances: 1, cids: [`${name}-cid`] });
|
||||
return `{${dataVar}.map((d, i) => <${name} key={i} d={d} cids={${cidsVar}[i]} styles={${stylesVar}[i]} />)}`;
|
||||
}
|
||||
|
||||
/** Every `<ident>.map(` in a module must resolve to a binding declared in that module —
|
||||
* a `const <ident>`, an `import { <ident> }`, a default import, or a function param. */
|
||||
function assertMapIdentsResolved(module: string): void {
|
||||
const mapIdents = [...module.matchAll(/([A-Za-z_$][\w$]*)\.map\(/g)].map((m) => m[1]!);
|
||||
for (const id of mapIdents) {
|
||||
const declared =
|
||||
new RegExp(`\\bconst\\s+${id}\\b`).test(module) ||
|
||||
new RegExp(`\\bimport\\b[^\\n]*\\b${id}\\b`).test(module) ||
|
||||
// destructured function param default: `{ ... ${id} = ... }`
|
||||
new RegExp(`[{,]\\s*${id}\\s*=`).test(module);
|
||||
assert.ok(declared, `map identifier ${id} has no matching declaration/import/param in module:\n${module}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeroModule(componentNames: string[]): string {
|
||||
const reg = emptyReg();
|
||||
const calls = componentNames.map((n) => addComponent(reg, n));
|
||||
// A section body that renders each component's `.map(` call, in order.
|
||||
const jsx = ` <div>\n${calls.map((c) => ` ${c}`).join("\n")}\n </div>`;
|
||||
const plan: SectionPlan = { roots: new Map([["hero-cid", "HeroSection"]]) };
|
||||
const sreg: SectionRegistry = { plan, modules: new Map([["HeroSection", jsx]]), order: ["HeroSection"] };
|
||||
const out = sectionFiles(sreg, reg);
|
||||
return out.files.find((f) => f.name === "HeroSection")!.module;
|
||||
}
|
||||
|
||||
describe("section data identifier derivation (digit-suffixed multi-word names)", () => {
|
||||
it("does not corrupt MediaTile2 when a shorter Tile2 shares its suffix", () => {
|
||||
// The exact bug shape: Tile2_data is a suffix of MediaTile2_data.
|
||||
const module = buildHeroModule(["Tile", "Tile2", "MediaTile2"]);
|
||||
// The map site and the param must agree on ONE identifier for MediaTile2's data.
|
||||
assert.match(module, /mediaTile2Data\.map\(/);
|
||||
assert.doesNotMatch(module, /Mediatile2Data/); // the corrupted (lowercase-t) form must never appear
|
||||
assertMapIdentsResolved(module);
|
||||
});
|
||||
|
||||
it("keeps a plain name and a Logo2-style digit name self-consistent", () => {
|
||||
const module = buildHeroModule(["Logo", "Logo2"]);
|
||||
// Logo2_data has Logo_data-ish neighbors; both map sites must stay resolvable.
|
||||
assert.match(module, /logo2Data\.map\(/);
|
||||
assert.doesNotMatch(module, /Logo2Data\.map\(/); // no PascalCase corruption of the usage
|
||||
assertMapIdentsResolved(module);
|
||||
});
|
||||
|
||||
it("every map identifier resolves for a suffix-overlapping cluster", () => {
|
||||
const module = buildHeroModule(["Card", "MediaCard", "Card2", "MediaCard2"]);
|
||||
assertMapIdentsResolved(module);
|
||||
assert.doesNotMatch(module, /Mediacard/); // no lowercase-c corruption
|
||||
});
|
||||
});
|
||||
@@ -64,3 +64,97 @@ describe("walker whitespace-only text nodes", () => {
|
||||
assert.deepEqual(section.children, []);
|
||||
});
|
||||
});
|
||||
|
||||
function findByClass(root: RawNode, cls: string): RawNode | null {
|
||||
if ((root.attrs?.class ?? "").split(/\s+/).includes(cls)) return root;
|
||||
for (const c of root.children) {
|
||||
if (isText(c)) continue;
|
||||
const hit = findByClass(c, cls);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("walker off-screen visibility", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.setViewportSize({ width: 375, height: 768 });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("marks a fixed off-screen-left drawer's un-hidden inner content invisible", async () => {
|
||||
// Real case (ooni.com): a slide-in search drawer is a position:fixed box parked at
|
||||
// x:-375 (right edge at 0) with visibility:hidden; its inner content sets
|
||||
// visibility:visible (so getComputedStyle reports it visible) but the whole box is
|
||||
// still off the left edge. The drawer must not leak into the clone's DOM text.
|
||||
const snap = await capture(`
|
||||
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
|
||||
transform:translateX(-100%);visibility:hidden;">
|
||||
<div class="inner" style="visibility:visible;width:343px;">
|
||||
<h2 class="drawer-title">Popular Searches</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="onscreen">Hello</p>`);
|
||||
const title = findByClass(snap.root, "drawer-title")!;
|
||||
assert.equal(title.visible, false, "off-screen-left drawer title should be invisible");
|
||||
// The genuinely on-screen paragraph is still visible (sanity that the gate isn't over-broad).
|
||||
const onscreen = findByClass(snap.root, "onscreen")!;
|
||||
assert.equal(onscreen.visible, true, "on-screen content stays visible");
|
||||
});
|
||||
|
||||
it("keeps a partially-overlapping negative-margin decoration visible", async () => {
|
||||
// A decoration pulled left by a negative margin so it straddles the left edge
|
||||
// (x:-40, width:200 -> right edge +160) still paints and must stay visible.
|
||||
const snap = await capture(`
|
||||
<div style="overflow:hidden;width:375px;">
|
||||
<div class="deco" style="width:200px;height:40px;margin-left:-40px;background:red;">peek</div>
|
||||
</div>`);
|
||||
const deco = findByClass(snap.root, "deco")!;
|
||||
assert.ok(deco.bbox.x < 0, "decoration starts off-screen left");
|
||||
assert.ok(deco.bbox.x + deco.bbox.width > 0, "but its right edge is on-screen");
|
||||
assert.equal(deco.visible, true, "partially-overlapping decoration stays visible");
|
||||
});
|
||||
|
||||
it("keeps normal in-flow content below the fold visible", async () => {
|
||||
// The page scrolls vertically, so content below the viewport is reachable and must
|
||||
// NOT be marked invisible (only position:fixed boxes are pinned).
|
||||
const snap = await capture(`
|
||||
<div style="height:2000px;"></div>
|
||||
<p class="belowfold" style="height:40px;">Below the fold</p>`);
|
||||
const below = findByClass(snap.root, "belowfold")!;
|
||||
assert.ok(below.bbox.y > 768, "content is below the 768px viewport");
|
||||
assert.equal(below.visible, true, "below-fold in-flow content stays visible");
|
||||
});
|
||||
|
||||
it("marks a computed-visibility:hidden subtree invisible", async () => {
|
||||
// A subtree whose computed visibility is actually hidden (and does not set
|
||||
// visibility:visible on any descendant) is not painted.
|
||||
const snap = await capture(`
|
||||
<div style="visibility:hidden;width:300px;">
|
||||
<span class="hidden-child">secret</span>
|
||||
</div>`);
|
||||
const child = findByClass(snap.root, "hidden-child")!;
|
||||
assert.equal(child.visible, false, "computed-hidden child is invisible");
|
||||
});
|
||||
|
||||
it("rejects a fixed box parked entirely below the viewport", async () => {
|
||||
// A position:fixed banner pinned below the viewport bottom never scrolls into view.
|
||||
const snap = await capture(`
|
||||
<div class="fixedlow" style="position:fixed;left:0;top:900px;width:375px;height:60px;">
|
||||
<span>hidden banner</span>
|
||||
</div>`);
|
||||
const fixed = findByClass(snap.root, "fixedlow")!;
|
||||
assert.equal(fixed.visible, false, "fixed box below the viewport is invisible");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user