From 7f99b76f664b9b4683c05ba8dd4ad0a01a57cbed Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 19:42:24 -0700 Subject: [PATCH] Fix build-breaking ident rewrite, blank-iframe grafting, and layout-gate regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- compiler/src/capture/graft.ts | 16 +- compiler/src/capture/walker.ts | 42 ++++ compiler/src/generate/app.ts | 5 +- compiler/src/generate/css.ts | 182 ++++++++++++++++- compiler/test/cloneFidelity.test.ts | 270 +++++++++++++++++++++++++ compiler/test/iframeGraft.test.ts | 12 +- compiler/test/sectionDataIdent.test.ts | 97 +++++++++ compiler/test/walker.test.ts | 94 +++++++++ 8 files changed, 708 insertions(+), 10 deletions(-) create mode 100644 compiler/test/cloneFidelity.test.ts create mode 100644 compiler/test/sectionDataIdent.test.ts diff --git a/compiler/src/capture/graft.ts b/compiler/src/capture/graft.ts index d98fded..98323cf 100644 --- a/compiler/src/capture/graft.ts +++ b/compiler/src/capture/graft.ts @@ -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"; diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts index 0a36096..360982e 100644 --- a/compiler/src/capture/walker.ts +++ b/compiler/src/capture/walker.ts @@ -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; }; diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 456ad77..1a10647 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -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"); diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index b343609..5a9058a 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -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 } | { 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 } | { 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 | 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(); + 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(); 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, 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, 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, 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" } diff --git a/compiler/test/cloneFidelity.test.ts b/compiler/test/cloneFidelity.test.ts new file mode 100644 index 0000000..4e0a092 --- /dev/null +++ b/compiler/test/cloneFidelity.test.ts @@ -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, children: IRChild[] = []): IRNode { + const computedByVp: Record = {}; + const bboxByVp: Record = {}; + const visibleByVp: Record = {}; + const sizingByVp: Record = {}; + 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{…}` block). */ +function baseRule(css: string, id: string): string { + const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`)); + return m?.[1] ?? ""; +} +/** Every `.c{…}` 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 (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; + 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); + 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, [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, [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); + 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, [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, /(? { + // N equal tracks. `content` is the container content width; `sum` is total track+gap extent. + function gridNode(id: string, perVp: Record): 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); + } + + 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); + 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); + 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, [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; + 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); + 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, [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); + 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, [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")}`); + }); +}); diff --git a/compiler/test/iframeGraft.test.ts b/compiler/test/iframeGraft.test.ts index c5dd0c2..96cc9e7 100644 --- a/compiler/test/iframeGraft.test.ts +++ b/compiler/test/iframeGraft.test.ts @@ -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"); diff --git a/compiler/test/sectionDataIdent.test.ts b/compiler/test/sectionDataIdent.test.ts new file mode 100644 index 0000000..b32272f --- /dev/null +++ b/compiler/test/sectionDataIdent.test.ts @@ -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
\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 `.map(` in a module must resolve to a binding declared in that module — + * a `const `, an `import { }`, 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 = `
\n${calls.map((c) => ` ${c}`).join("\n")}\n
`; + 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 + }); +}); diff --git a/compiler/test/walker.test.ts b/compiler/test/walker.test.ts index 6d9c0eb..a681f7c 100644 --- a/compiler/test/walker.test.ts +++ b/compiler/test/walker.test.ts @@ -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(` + +

Hello

`); + 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(` +
+
peek
+
`); + 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(` +
+

Below the fold

`); + 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(` +
+ secret +
`); + 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(` +
+ hidden banner +
`); + const fixed = findByClass(snap.root, "fixedlow")!; + assert.equal(fixed.visible, false, "fixed box below the viewport is invisible"); + }); +});