diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts index 818efaa..1ea6c0f 100644 --- a/compiler/src/capture/walker.ts +++ b/compiler/src/capture/walker.ts @@ -48,6 +48,14 @@ export type RawNode = { // libraries, FontFaceObserver): absolutely positioned, parked far off-screen, and non-painting. // Never user-visible; excluded from emission so it doesn't ship as page markup. probe?: boolean; + // This element hosts an OPEN shadow root: its serialized children are the composed (flattened) + // shadow tree — the shadowRoot's children with each replaced by its assigned light-DOM + // nodes — NOT the host's light-DOM children. Custom elements (``) render all their + // swatches/title/price inside this shadow tree, so a childNodes-only walk captured them empty. + shadowHost?: boolean; + // This node was serialized from inside a shadow tree (a shadowRoot descendant, or the shadow + // subtree of a slotted light node). Tagged so generation can emit it as ordinary light DOM. + inShadow?: boolean; sizing?: RawSizing; before?: RawStyle; after?: RawStyle; @@ -316,7 +324,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { || bbox.x >= OFFSCREEN_PROBE_PX + vpW || bbox.y >= OFFSCREEN_PROBE_PX + pageH; }; - const serializeElement = (el: Element): RawNode | null => { + const serializeElement = (el: Element, inShadow = false): RawNode | null => { if (nodeCount >= MAX_NODES) { truncated = true; return null; } const tag = el.tagName.toLowerCase(); nodeCount++; @@ -445,6 +453,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { bbox, visible: isVisible(el, cs, bbox), ...(isProbe(cs, bbox) ? { probe: true } : {}), + ...(inShadow ? { inShadow: true } : {}), ...(sizing ? { sizing } : {}), children: [], }; @@ -488,8 +497,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { // code blocks with highlighted spans separated by newlines). const preserveWs = /^(pre|pre-wrap|break-spaces)/.test(cs.whiteSpace || ""); + // Composed (flattened) child list — the tree the user actually sees, matching what + // getComputedStyle/getBoundingClientRect already report for every node: + // - An open shadow HOST renders its shadow tree, not its light children: iterate the + // shadowRoot's children and tag them (and the host) so generation emits them as light DOM. + // - A is a placeholder for the light-DOM nodes distributed into it: replace it with its + // assignedNodes (or its own fallback children when nothing is assigned). Assigned nodes are + // the author's real light content, so they are NOT tagged inShadow — only genuine shadow + // descendants are. Walking the shadow tree (never the host's raw light children) means a + // slotted child is serialized once, at the slot position, with no double-emission. + const sr = (el as HTMLElement).shadowRoot; // open roots only; closed roots are null here + let childInShadow = inShadow; + let childNodes: Node[]; + if (sr) { + node.shadowHost = true; + childInShadow = true; + childNodes = Array.from(sr.childNodes); + } else { + childNodes = Array.from(el.childNodes); + } + // Recurse children (elements + text nodes), preserving order. - for (const child of Array.from(el.childNodes)) { + for (const child of childNodes) { if (child.nodeType === Node.TEXT_NODE) { const t = child.textContent || ""; if (preserveWs && t.length > 0) { @@ -509,8 +538,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { } if (child.nodeType !== Node.ELEMENT_NODE) continue; const childEl = child as Element; - if (SKIP_TAGS.has(childEl.tagName.toLowerCase())) continue; - const sn = serializeElement(childEl); + const childTag = childEl.tagName.toLowerCase(); + if (SKIP_TAGS.has(childTag)) continue; + // A slot nested deeper inside a shadow tree: expand its assigned light nodes in place. The + // assigned nodes are light DOM (author content), so they drop back to inShadow=false. + if (childTag === "slot" && childInShadow) { + const assigned = (childEl as HTMLSlotElement).assignedNodes({ flatten: true }); + const slotKids = assigned.length ? assigned : Array.from(childEl.childNodes); + for (const sk of slotKids) { + if (sk.nodeType === Node.TEXT_NODE) { + const t = sk.textContent || ""; + if (t.trim().length > 0) node.children.push({ text: t }); + continue; + } + if (sk.nodeType !== Node.ELEMENT_NODE) continue; + const skEl = sk as Element; + if (SKIP_TAGS.has(skEl.tagName.toLowerCase())) continue; + const skn = serializeElement(skEl, assigned.length ? false : true); + if (skn) node.children.push(skn); + } + continue; + } + const sn = serializeElement(childEl, childInShadow); if (sn) node.children.push(sn); } diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 5b5f021..e4c2361 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -369,11 +369,20 @@ export function propsList(node: IRNode, assetMap: Map, sourceUrl if (kept.length === 0) continue; value = kept.join(", "); } else if (key === "href") { - // Preserve in-page anchors. For multi-route sites a linkRewrite maps internal - // links to the generated clone routes (and collapsed-collection links to their - // representative); otherwise absolutize so it never 404s inside the clone - // (external navigation is allowed by the rubric). - if (!value.startsWith("#")) value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl); + // A `javascript:*` href (announcement bars, JS-driven buttons authored as links) is a + // script trigger, not a navigable URL. React refuses to render one: it rewrites the + // attribute to a long `javascript:throw new Error('React has blocked a javascript: URL…')` + // string, which no longer matches the source href in the link gate. The behaviour is + // script we don't reproduce anyway, so emit an inert `#` and keep the anchor navigable-inert. + if (/^\s*javascript:/i.test(value)) { + value = "#"; + } else if (!value.startsWith("#")) { + // Preserve in-page anchors. For multi-route sites a linkRewrite maps internal + // links to the generated clone routes (and collapsed-collection links to their + // representative); otherwise absolutize so it never 404s inside the clone + // (external navigation is allowed by the rubric). + value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl); + } } let reactName = isCustom ? key : (ATTR_RENAME[key] ?? key); diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index c0dc660..0140180 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -1034,7 +1034,16 @@ function isCircularShrinkSlide(node: IRNode, parentNode: IRNode | undefined, vie 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 + // A block-level in-flow box (block/flex/grid/table/list-item) takes its width FROM its + // container by definition: its `width:auto` computed size IS the fill size, so the probe + // reports wAuto AND wFill both true — indistinguishable from a genuine content-sized source. + // Such a child still DERIVES its width from the slide (it never establishes one), so it must + // count as a fill child, not veto the circular case. Only wFill matters for a block child; + // wAuto is redundant with it. Inline / inline-block children (whose auto width is genuinely + // content-derived and can size the slide) keep the strict wFill && !wAuto test. + const cDisp = ccs.display || ""; + const blockLevel = /^(block|flex|grid|table|list-item|flow-root)$/.test(cDisp); + if (csz && csz.wFill === true && (blockLevel || csz.wAuto === false)) { fillChildren++; continue; } // fills/derives width from the slide return false; // a genuine in-flow width source → not circular } if (fillChildren === 0) return false; diff --git a/compiler/src/normalize/ir.ts b/compiler/src/normalize/ir.ts index 839a595..5842604 100644 --- a/compiler/src/normalize/ir.ts +++ b/compiler/src/normalize/ir.ts @@ -251,6 +251,54 @@ export function isIdentityTransform(value: string | undefined): boolean { return false; } +/** Parse a CSS length to px (number), 0 for `auto`/empty/non-px. */ +function pfLen(v: string | undefined): number { + if (!v) return 0; + const n = parseFloat(v); + return Number.isFinite(n) ? n : 0; +} + +/** The horizontal translate (matrix `e`) of a computed transform, or null if the transform is not a + * pure-2D matrix/matrix3d with a definite tx (rotation/scale/skew or 3D perspective → null: we only + * re-anchor plain horizontal track offsets). `matrix(a,b,c,d,e,f)` → e; `matrix3d(...)` → m41. */ +function translateXOf(value: string | undefined): number | null { + if (!value || value === "none") return null; + const m = /^matrix\(([^)]*)\)$/.exec(value.trim()); + if (m) { + const n = m[1]!.split(",").map((s) => parseFloat(s.trim())); + if (n.length !== 6 || n.some((x) => !Number.isFinite(x))) return null; + if (n[1] !== 0 || n[2] !== 0) return null; // has rotation/skew → not a plain slide + return n[4]!; + } + const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim()); + if (m3) { + const n = m3[1]!.split(",").map((s) => parseFloat(s.trim())); + if (n.length !== 16 || n.some((x) => !Number.isFinite(x))) return null; + // Require an otherwise-identity 3D matrix apart from the translate column (indices 12/13/14). + const idExceptTranslate = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, /*12*/ 0, /*13*/ 0, /*14*/ 0, 1]; + for (let i = 0; i < 16; i++) { if (i === 12 || i === 13 || i === 14) continue; if (n[i] !== idExceptTranslate[i]) return null; } + return n[12]!; + } + return null; +} + +/** Return `value` with its horizontal translate replaced by `tx` (keeps the rest of the matrix). */ +function setTranslateX(value: string, tx: number): string { + const m = /^matrix\(([^)]*)\)$/.exec(value.trim()); + if (m) { + const n = m[1]!.split(",").map((s) => s.trim()); + n[4] = String(tx); + return `matrix(${n.join(", ")})`; + } + const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim()); + if (m3) { + const n = m3[1]!.split(",").map((s) => s.trim()); + n[12] = String(tx); + return `matrix3d(${n.join(", ")})`; + } + return value; +} + /** * Normalize per-viewport transform values so identity is represented uniformly as the literal * `none`. Two problems this closes, both in the per-viewport delta emission downstream: @@ -506,6 +554,8 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? // interactive panel so its whole (possibly display:none) subtree is retained. const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? ""); const keptChildren: IRChild[] = []; + // Per element child, in original order: was it kept? (for track-transform re-anchoring below.) + const elemKept: Array<{ child: IRNode; kept: boolean }> = []; let hasVisibleDescendant = false; let hasText = false; for (const c of node.children) { @@ -522,16 +572,81 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? if (keepAll) { prune(c, true); keptChildren.push(c); + elemKept.push({ child: c, kept: true }); if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true; } else if (prune(c, false) || keepSource) { keptChildren.push(c); + elemKept.push({ child: c, kept: true }); if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true; + } else { + elemKept.push({ child: c, kept: false }); } } node.children = keptChildren; + reanchorTrackTransform(node, elemKept); if (keepAll) return true; const selfVisible = Object.values(node.visibleByVp).some(Boolean); - return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML; + return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML || isSizedInvisibleSpacer(node); + }; + // An in-flow `visibility:hidden` box with a nonzero border box is LOAD-BEARING geometry: it is + // invisible (so `visibleByVp` is false and the visibility prune would drop it), but it still takes + // up its captured width/height in normal flow — a spacer/ghost column that reserves the row height + // its absolutely-positioned siblings paint into. Dropping it collapses the column and shifts + // everything below it up. Keep it as an empty sized placeholder (generation emits its w/h with + // `visibility:hidden` and no content). `display:none`-everywhere nodes (never in flow, zero box) + // and out-of-flow probes stay pruned; font-metric probes are already dropped before pruning. + const isSizedInvisibleSpacer = (node: IRNode): boolean => { + for (const vp of Object.keys(node.computedByVp).map(Number)) { + const cs = node.computedByVp[vp]; const bb = node.bboxByVp[vp]; + if (!cs || !bb) continue; + if ((cs.display || "") === "none") continue; // not in flow here + const vis = cs.visibility || "visible"; + if (vis !== "hidden" && vis !== "collapse") continue; // only invisible-but-laid-out boxes + const pos = cs.position || "static"; + if (pos !== "static" && pos !== "relative") continue; // in-flow geometry only (not absolute/fixed) + if (bb.width > 0 && bb.height > 0) return true; // reserves real space + } + return false; + }; + // Re-anchor a horizontally-translated TRACK container after pruning removed leading in-flow + // children. A settled loop carousel (Splide/Swiper/Slick) parks its track with a baked + // `translateX(-N)` and prepends invisible clone slides that occupy exactly [-N, 0]; the first REAL + // slide then paints at x=0. The visibility prune drops the off-screen clones but the baked + // translateX survives verbatim, so every kept slide sits N px offscreen-left (an empty band). When + // emission drops the leading in-flow children of such a translated track, subtract their aggregate + // outer width from the baked translateX per viewport so the first KEPT child lands where it was + // captured. NON-animated baked offsets only — animation-owned transforms are already neutralized to + // `none` upstream (neutralizeAnimatedTransforms), so a still-present translate here is a static bake. + const reanchorTrackTransform = (node: IRNode, elemKept: Array<{ child: IRNode; kept: boolean }>): void => { + if (elemKept.length === 0) return; + // Only act when a leading RUN of element children was dropped (the clone slides precede the reals). + const firstKeptIdx = elemKept.findIndex((e) => e.kept); + if (firstKeptIdx <= 0) return; // nothing dropped ahead of the first kept child (or nothing kept) + for (const vp of Object.keys(node.computedByVp).map(Number)) { + const cs = node.computedByVp[vp]; + if (!cs || !cs.transform) continue; + const tx = translateXOf(cs.transform); + if (tx === null || tx === 0) continue; // no baked horizontal offset to re-anchor at this vp + // Aggregate the outer (margin-box) width of the dropped leading in-flow siblings at this vp. + let droppedW = 0; + for (let i = 0; i < firstKeptIdx; i++) { + const { child } = elemKept[i]!; + const ccs = child.computedByVp[vp]; const cb = child.bboxByVp[vp]; + if (!ccs || !cb) continue; + if ((ccs.display || "") === "none") continue; + const cpos = ccs.position || "static"; + if (cpos !== "static" && cpos !== "relative") continue; // out-of-flow slides don't advance the track + droppedW += cb.width + pfLen(ccs.marginLeft) + pfLen(ccs.marginRight); + } + if (droppedW === 0) continue; + // translateX is negative (track pulled left); dropping the leading clones that filled [tx, 0] + // means the reals shift right by droppedW → add it back. Re-anchor toward 0. + const next = tx + droppedW; + // Only re-anchor when it moves the track TOWARD the origin and doesn't overshoot past it — a + // guard so a partial mismatch can't push content the wrong way. Round to match capture precision. + if (Math.abs(next) >= Math.abs(tx)) continue; + cs.transform = setTranslateX(cs.transform, Math.round(next * 100) / 100); + } }; const childHasVisible = (n: IRNode): boolean => { for (const c of n.children) { diff --git a/compiler/src/validate/gates.ts b/compiler/src/validate/gates.ts index 23c2ba8..533cb13 100644 --- a/compiler/src/validate/gates.ts +++ b/compiler/src/validate/gates.ts @@ -313,8 +313,12 @@ function isValidRetag(srcTag: string, genTag: string): boolean { return genTag === "div" && /^(ul|ol|menu|dl|p|h[1-6])$/.test(srcTag); // content-model → div } -function normHref(href: string, origin: string): string { +export function normHref(href: string, origin: string): string { if (!href) return ""; + // A `javascript:*` href is a script trigger, not a navigable URL. Generation emits an inert `#` + // for it (React blocks the literal), so treat every javascript: href — on either side — as `#` + // and let the two sides match instead of failing on the un-reproducible script string. + if (/^\s*javascript:/i.test(href)) return "#"; if (href.startsWith("#")) return href; try { const u = new URL(href, origin); diff --git a/compiler/test/codegenHygiene.test.ts b/compiler/test/codegenHygiene.test.ts index b84301f..0d447d7 100644 --- a/compiler/test/codegenHygiene.test.ts +++ b/compiler/test/codegenHygiene.test.ts @@ -11,6 +11,7 @@ import { PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, + propsList, type ComponentRegistry, } from "../src/generate/app.js"; import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js"; @@ -165,3 +166,34 @@ describe("lottie-web is declared when its import is emitted (fix 7)", () => { } }); }); + +// ---- FIX 5: javascript: hrefs are sanitized to an inert '#' ---- +// React refuses to render a `javascript:*` href verbatim — it rewrites it to a long +// `javascript:throw new Error('React has blocked a javascript: URL…')` string that no longer matches +// the source href in the link gate. Emit an inert `#` instead (the script behaviour isn't reproduced). +describe("javascript: hrefs are emitted as an inert '#' (FIX 5)", () => { + const hrefOf = (n: IRNode): string | undefined => { + const p = propsList(n, new Map(), "https://example.test/").find(([k]) => k === "href"); + return p ? JSON.parse(p[1]) : undefined; + }; + it("rewrites a javascript: href to #", () => { + const a = node("n1", "a", computed()); + a.attrs = { href: "Javascript:{}" }; + assert.equal(hrefOf(a), "#", "a javascript: href is sanitized to #"); + }); + it("rewrites javascript:void(0) too (case-insensitive, with args)", () => { + const a = node("n1", "a", computed()); + a.attrs = { href: "javascript:void(0)" }; + assert.equal(hrefOf(a), "#"); + }); + it("leaves a normal in-page anchor href untouched", () => { + const a = node("n1", "a", computed()); + a.attrs = { href: "#section" }; + assert.equal(hrefOf(a), "#section", "a real fragment link is preserved"); + }); + it("leaves an ordinary external href resolved (not collapsed to #)", () => { + const a = node("n1", "a", computed()); + a.attrs = { href: "https://example.test/products" }; + assert.equal(hrefOf(a), "https://example.test/products"); + }); +}); diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 237fe7c..b409c31 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -820,3 +820,47 @@ describe("generateCss text-wrap", () => { assert.ok(!/text-wrap/.test(baseRule(css, "n2")), `inherited child does not re-emit (got: ${baseRule(css, "n2")})`); }); }); + +// FIX 4 — a library-sized carousel slide (a shrink-0 flex item with an injected inline `width:Npx`) +// whose only in-flow child is a display:block box. A block box's `width:auto` IS its fill width, so +// the sizing probe reports the child wAuto AND wFill both true. The circular-slide detector must count +// that block-level fill child as width-DERIVING (it never establishes a width) and pin the slide's +// captured px — otherwise the guard misreads the child as a genuine content-width source, bails, and +// the slide content-sizes to a different width (ragged carousel rails). +describe("generateCss circular carousel slide with a block-level fill child (FIX 4)", () => { + // The slide's child is display:block: its auto width equals the fill width, so BOTH probe bits fire. + const blockFill = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true }); + function slideRow() { + // Uniform 220px slides at every viewport (Splide's inline width) inside a flex track. + const mkSlide = (id: string, childId: string): IRNode => { + const child = xNode(childId, "div", { + 375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() }, + 768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() }, + 1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() }, + }, [{ text: "Men's Tops & Shirts" } as IRChild]); + const slideCs = { display: "block", position: "static", flexShrink: "0", width: "220px" }; + return xNode(id, "div", { + 375: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } }, + 768: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } }, + 1280: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } }, + }, [child]); + }; + const trackCs = { display: "flex", position: "static" }; + const track = xNode("n1", "ul", { + 375: { cs: trackCs, bbox: { x: 0, y: 0, width: 375, height: 357 } }, + 768: { cs: trackCs, bbox: { x: 0, y: 0, width: 768, height: 357 } }, + 1280: { cs: trackCs, bbox: { x: 0, y: 0, width: 1280, height: 357 } }, + }, [mkSlide("n2", "n3"), mkSlide("n4", "n5")]); + return xNode("n0", "body", { + 375: { bbox: { x: 0, y: 0, width: 375, height: 357 } }, + 768: { bbox: { x: 0, y: 0, width: 768, height: 357 } }, + 1280: { bbox: { x: 0, y: 0, width: 1280, height: 357 } }, + }, [track]); + } + + it("pins the slide's captured width even though its block child reports wAuto:true", () => { + const css = generateCss(xIr(slideRow()), new Map()); + const slide = allRulesX(css, "n2"); + assert.ok(/width:220px/.test(slide), `the library-sized slide must keep its 220px width, got: ${slide}`); + }); +}); diff --git a/compiler/test/gates.test.ts b/compiler/test/gates.test.ts index 311bcda..5dd9258 100644 --- a/compiler/test/gates.test.ts +++ b/compiler/test/gates.test.ts @@ -1,6 +1,24 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { letterSpacingEquivalent } from "../src/validate/gates.js"; +import { letterSpacingEquivalent, normHref } from "../src/validate/gates.js"; + +// FIX 5 — the link gate must not fail a javascript: source href against the clone's sanitized value. +// Generation emits an inert `#` for a `javascript:*` href (React blocks the literal), so normHref +// collapses every javascript: href — on either side — to `#`, letting the two sides match. +describe("normHref collapses javascript: hrefs to # (FIX 5)", () => { + const origin = "https://example.test"; + it("normalizes a javascript: source href to #", () => { + assert.equal(normHref("Javascript:{}", origin), "#"); + assert.equal(normHref("javascript:void(0)", origin), "#"); + }); + it("makes a javascript: source match the emitted # value", () => { + assert.equal(normHref("javascript:{}", origin), normHref("#", origin), "source and clone agree"); + }); + it("still distinguishes a real fragment from a full URL", () => { + assert.equal(normHref("#top", origin), "#top"); + assert.equal(normHref("https://example.test/x/", origin), "https://example.test/x"); + }); +}); // Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after // snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then diff --git a/compiler/test/irPrune.test.ts b/compiler/test/irPrune.test.ts index f65e4a0..f4f02a0 100644 --- a/compiler/test/irPrune.test.ts +++ b/compiler/test/irPrune.test.ts @@ -182,3 +182,133 @@ describe("neutralizeAnimatedTransforms (Defect C)", () => { assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase"); }); }); + +/** A RawNode with an explicit computed style and bbox (same at every viewport). Unlike `raw()` this + * lets a test author invisible-but-laid-out boxes, transforms, margins, and positions directly. */ +function rawS( + tag: string, + computed: Record, + bbox: { x: number; y: number; width: number; height: number }, + children: RawChild[] = [], + visible = true, + attrs: Record = {}, +): RawNode { + return { tag, attrs, computed, bbox, visible, children }; +} + +// FIX 2 — an in-flow `visibility:hidden` box with a nonzero border box is load-bearing geometry: it +// reserves the row height its absolutely-positioned siblings paint into. It is invisible (so the plain +// visibility prune would drop it), but dropping it collapses the column. It must survive as a sized +// placeholder; a genuinely empty display:none-everywhere box must still prune. +describe("IR prune keeps sized invisible (visibility:hidden) spacers (FIX 2)", () => { + it("keeps an in-flow visibility:hidden ghost column with a nonzero bbox", () => { + const ghost = rawS( + "div", + { display: "flex", position: "static", visibility: "hidden" }, + { x: 0, y: 0, width: 650, height: 723 }, + // its children are themselves invisible (they set no visibility:visible) — the box is a spacer + [rawS("img", { display: "block", position: "static", visibility: "hidden" }, { x: 0, y: 0, width: 650, height: 240 }, [], false)], + false, + ); + // the real content is an absolutely-positioned sibling layered over the ghost's reserved space + const painted = rawS("img", { display: "block", position: "absolute", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [], true); + const column = rawS("div", { display: "block", position: "relative", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [ghost, painted], true); + const body = raw("body", {}, [column]); + + const root = buildFixtureIR(body); + const kept = findByTag(root, "div"); + assert.ok(kept, "the column survives"); + // The ghost div (a second nested div) must still be present as a sized placeholder. + const divs: IRNode[] = []; + const collect = (n: IRNode): void => { if (n.tag === "div") divs.push(n); for (const c of n.children) if (!isTextChild(c)) collect(c as IRNode); }; + collect(root); + // column + ghost = 2 divs (body is not a div) + assert.equal(divs.length, 2, "the visibility:hidden ghost column is retained, not pruned"); + const ghostIR = divs.find((d) => d.computedByVp[1280]?.visibility === "hidden"); + assert.ok(ghostIR, "the retained ghost carries visibility:hidden"); + assert.ok(ghostIR!.bboxByVp[1280]!.height > 0, "the ghost keeps its load-bearing height"); + }); + + it("still prunes a display:none-everywhere empty box (no resurrection)", () => { + const hidden = rawS("div", { display: "none", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 0, height: 0 }, [], false); + const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [hidden], true)]); + const root = buildFixtureIR(body); + assert.equal(findByTag(root, "div"), null, "a display:none-everywhere box stays pruned"); + }); + + it("does not keep an out-of-flow (absolute) visibility:hidden box as a spacer", () => { + // Absolute boxes take no space in flow, so an invisible absolute box is not load-bearing geometry. + const absHidden = rawS("div", { display: "block", position: "absolute", visibility: "hidden" }, { x: 0, y: 0, width: 300, height: 300 }, [], false); + const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [absHidden], true)]); + const root = buildFixtureIR(body); + assert.equal(findByTag(root, "div"), null, "an out-of-flow invisible box is not kept as a spacer"); + }); +}); + +// FIX 3 — a settled loop carousel parks its track with a baked translateX and prepends invisible clone +// slides that occupy exactly [translateX, 0]; the first REAL slide paints at x=0. Pruning drops the +// off-screen clones, but the baked translateX would survive verbatim and push every real slide +// offscreen-left. Re-anchor the track's translateX by the aggregate outer width of the dropped leading +// clones so the first kept slide lands at its captured position. +describe("IR prune re-anchors a translated track after dropping leading clones (FIX 3)", () => { + it("re-anchors translateX toward 0 by the dropped leading clone width", () => { + // Three off-screen leading loop clones, each 200px wide (translateX = -600 = -(3×200)); two reals. + // The clones are OFF-SCREEN (visible:false via the off-screen test), NOT visibility:hidden — so + // they prune and the FIX-2 sized-invisible-spacer carve-out (which requires visibility:hidden) does + // not resurrect them. This mirrors a real settled Splide loop's leading clones. + const clone = (i: number): RawNode => + rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: -600 + i * 200, y: 0, width: 200, height: 100 }, [], false); + const real = (i: number): RawNode => + rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true); + const track = rawS( + "ul", + { display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -600, 0)" }, + { x: 0, y: 0, width: 1000, height: 100 }, + [clone(0), clone(1), clone(2), real(0), real(1)], + true, + ); + const body = raw("body", {}, [track]); + const root = buildFixtureIR(body); + const ul = findByTag(root, "ul")!; + // The clones are pruned (invisible everywhere), leaving the two real slides. + const slides = ul.children.filter((c) => !isTextChild(c)); + assert.equal(slides.length, 2, "only the real slides survive"); + // The baked -600 translate is re-anchored to 0 (−600 + 3×200). + assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "track translateX re-anchored to origin"); + assert.equal(ul.computedByVp[375]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "re-anchored at every viewport"); + }); + + it("leaves a track untouched when no leading children are dropped", () => { + const real = (i: number): RawNode => + rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true); + const track = rawS( + "ul", + { display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -120, 0)" }, + { x: 0, y: 0, width: 400, height: 100 }, + [real(0), real(1)], + true, + ); + const body = raw("body", {}, [track]); + const root = buildFixtureIR(body); + const ul = findByTag(root, "ul")!; + assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -120, 0)", "a track with no dropped leading children keeps its offset"); + }); + + it("does not re-anchor when the dropped leading siblings are out of flow", () => { + const absClone = (i: number): RawNode => + rawS("li", { display: "block", position: "absolute", visibility: "hidden" }, { x: -400 + i * 200, y: 0, width: 200, height: 100 }, [], false); + const real = (i: number): RawNode => + rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true); + const track = rawS( + "ul", + { display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -80, 0)" }, + { x: 0, y: 0, width: 400, height: 100 }, + [absClone(0), absClone(1), real(0), real(1)], + true, + ); + const body = raw("body", {}, [track]); + const root = buildFixtureIR(body); + const ul = findByTag(root, "ul")!; + assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -80, 0)", "out-of-flow clones don't advance the track, so no re-anchor"); + }); +}); diff --git a/compiler/test/walker.test.ts b/compiler/test/walker.test.ts index 4172c04..d60277f 100644 --- a/compiler/test/walker.test.ts +++ b/compiler/test/walker.test.ts @@ -349,3 +349,89 @@ describe("walker text-wrap capture", () => { assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured"); }); }); + +describe("walker shadow-DOM composed-tree serialization (FIX 1)", () => { + let browser: Browser; + let page: Page; + before(async () => { + browser = await chromium.launch(); + page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + }); + after(async () => { + await browser.close(); + }); + + // Attach an OPEN shadow root to any host carrying data-shadow, injecting its data-shadow value as + // the shadow tree HTML. Runs in-page before collectPage so getComputedStyle/bbox see the composed tree. + const capture = async (html: string) => { + await page.setContent(html); + await page.evaluate(() => { + for (const host of Array.from(document.querySelectorAll("[data-shadow]"))) { + const markup = host.getAttribute("data-shadow") || ""; + const sr = (host as HTMLElement).attachShadow({ mode: "open" }); + sr.innerHTML = markup; + } + }); + await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); + return page.evaluate(collectPage); + }; + + it("serializes an open custom element's shadow tree as the host's children", async () => { + // A `` web component renders its swatches/title/price INSIDE its shadow root; a + // childNodes-only walk captured it empty. The composed walk must surface the shadow content. + const snap = await capture(` + `); + const host = findByTag(snap.root, "product-info")!; + assert.ok(host, "the custom element host is present"); + assert.equal(host.shadowHost, true, "the host is tagged as a shadow host"); + assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Magnolia Shirt $78.00", "the shadow tree text is captured"); + const title = findByClass(host, "pi-title")!; + assert.ok(title, "a shadow descendant node is serialized"); + assert.equal(title.inShadow, true, "shadow descendants are tagged inShadow"); + assert.ok(title.bbox.width > 0, "shadow nodes get real bboxes via getBoundingClientRect"); + }); + + it("renders the FLATTENED tree: a is replaced by its assigned light-DOM nodes", async () => { + // The host's light child (the assigned node) renders at the slot position — once, and NOT tagged + // inShadow (it is the author's real content). Shadow chrome around the slot still appears. + const snap = await capture(` +

Vintage Sunset T-Shirt

`); + const host = findByTag(snap.root, "my-card")!; + const frame = findByClass(host, "card-frame")!; + assert.ok(frame, "the shadow frame around the slot is serialized"); + assert.equal(frame.inShadow, true, "the shadow frame is tagged inShadow"); + const slotted = findByClass(host, "slotted")!; + assert.ok(slotted, "the slotted light child renders at the slot position"); + assert.equal(textRun(slotted).trim(), "Vintage Sunset T-Shirt"); + assert.ok(!slotted.inShadow, "the slotted light child is NOT tagged inShadow (author content)"); + // No double-serialization: exactly one node carries the slotted text. + let count = 0; + const countText = (n: RawNode): void => { + if (n.children.some((c) => isText(c) && c.text.includes("Vintage Sunset T-Shirt"))) count++; + for (const c of n.children) if (!isText(c)) countText(c as RawNode); + }; + countText(host); + assert.equal(count, 1, "the slotted child is serialized exactly once"); + }); + + it("uses fallback content when nothing is assigned", async () => { + const snap = await capture(` + `); + const host = findByTag(snap.root, "my-badge")!; + assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Default Label", "unfilled slot falls back to its own content"); + }); + + it("does not tag ordinary light-DOM nodes as shadow", async () => { + const snap = await capture(`

light

`); + const plain = findByClass(snap.root, "plain")!; + assert.ok(!plain.shadowHost, "a plain div is not a shadow host"); + assert.ok(!plain.inShadow, "plain light DOM is not tagged inShadow"); + }); +});