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, collectNodeRules, RESET_CSS } from "../src/generate/css.js"; const VPS = [375, 1280]; const CANONICAL = 1280; /** Computed style with the minimum the emitter reads, per viewport. */ function computed(over: StyleMap = {}): StyleMap { return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over }; } function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], visible = true): IRNode { const computedByVp: Record = {}; const bboxByVp: Record = {}; const visibleByVp: Record = {}; for (const vp of VPS) { computedByVp[vp] = { ...cs }; bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 }; visibleByVp[vp] = visible; } return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children }; } function irWith(root: IRNode): IR { return { doc: { sourceUrl: "https://example.test/css", title: "CSS 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: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])), nodeCount: 4, 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] ?? ""; } // Root scroll-lock un-clamp: a popup vendor locks the page with // body{position:absolute;overflow:hidden;height:100vh}, which the capture bakes as the body's // computed style AND collapses document.scrollHeight down to the clamp (so the captured-scrollHeight // trigger can never fire). The IR's in-flow CONTENT EXTENT (a footer laid out at y=5000) still // exceeds the clamp, so the un-clamp must fire off that signal and strip the lock. describe("generateCss root scroll-lock un-clamp (content-extent trigger)", () => { it("drops the body height/position/overflow lock when IR content extent exceeds the clamp", () => { const footer = node("n1", "footer", computed()); // Footer sits far below the one-viewport clamp at every viewport. for (const vp of VPS) footer.bboxByVp[vp] = { x: 0, y: 4000, width: vp, height: 1000 }; // bottom = 5000 const bodyCs = computed({ position: "absolute", overflow: "hidden", overflowY: "hidden", height: "812px", top: "0px", left: "0px" }); const root = node("n0", "body", bodyCs, [footer]); for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 812 }; // perViewport.scrollHeight stays collapsed to 800 (the lock's doing) — so ONLY the content // extent can trigger the un-clamp. const css = generateCss(irWith(root), new Map()); const body = baseRule(css, "n0"); assert.ok(!/height:812px/.test(body), `body height clamp dropped (got: ${body})`); assert.ok(!/position:absolute/.test(body), `body position:absolute stripped (got: ${body})`); assert.ok(/overflow-y:visible/.test(body), `overflow-y forced visible (got: ${body})`); assert.ok(!/overflow:hidden/.test(body), `overflow:hidden not emitted (got: ${body})`); }); it("does NOT un-clamp a genuine one-screen page (content extent within the clamp)", () => { const inner = node("n1", "div", computed()); for (const vp of VPS) inner.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 700 }; const bodyCs = computed({ overflow: "hidden", overflowY: "hidden", height: "800px" }); const root = node("n0", "body", bodyCs, [inner]); for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 800 }; const css = generateCss(irWith(root), new Map()); const body = baseRule(css, "n0"); // The clamp is legitimate (content fits) — height kept, overflow not forced to visible. assert.ok(/height:800px/.test(body), `legit body height preserved (got: ${body})`); }); }); describe("generateCss list markers", () => { it("re-establishes list-style on a
    whose disc equals the parent's initial value", () => { // list-style-type's initial value is `disc` on EVERY element, so a real
      // equals its parent
      's computed value — but the reset (`ul, ol, menu // { list-style: none; }`) breaks the inheritance chain, so it must still emit. const li = node("n2", "li", computed({ display: "list-item" })); const ul = node("n1", "ul", computed(), [li]); const root = node("n0", "body", computed(), [ul]); const css = generateCss(irWith(root), new Map()); assert.ok(baseRule(css, "n1").includes("list-style-type:disc")); // The
    • inherits from the ul (not reset), so parent-equality elision still applies. assert.ok(!baseRule(css, "n2").includes("list-style-type")); }); it("does not emit list-style-type on non-list tags at the initial disc", () => { const div = node("n1", "div", computed()); const root = node("n0", "body", computed(), [div]); const css = generateCss(irWith(root), new Map()); assert.ok(!baseRule(css, "n1").includes("list-style-type")); }); }); describe("generateCss visibility", () => { it("emits visibility:hidden for a node hidden at the canonical viewport", () => { const hidden = node("n1", "div", computed({ visibility: "hidden" }), [], false); const shown = node("n2", "div", computed()); const root = node("n0", "body", computed(), [hidden, shown]); const css = generateCss(irWith(root), new Map()); assert.ok(baseRule(css, "n1").includes("visibility:hidden")); assert.ok(!baseRule(css, "n2").includes("visibility")); }); it("restores inherited visibility at a band where the node is shown", () => { const n = node("n1", "div", computed({ visibility: "hidden" }), [], false); n.computedByVp[375]!.visibility = "visible"; n.visibleByVp[375] = true; const root = node("n0", "body", computed(), [n]); const css = generateCss(irWith(root), new Map()); assert.ok(baseRule(css, "n1").includes("visibility:hidden")); const band = css.match(/@media \(max-width: \d+px\) \{\n([\s\S]*?)\n\}/); assert.ok(band?.[1]?.includes("visibility:inherit")); }); it("stays silent on the descendants of a hidden subtree (inheritance covers them)", () => { const child = node("n2", "div", computed({ visibility: "hidden" }), [], false); const parent = node("n1", "div", computed({ visibility: "hidden" }), [child], false); const root = node("n0", "body", computed(), [parent]); const css = generateCss(irWith(root), new Map()); assert.ok(baseRule(css, "n1").includes("visibility:hidden")); assert.ok(!baseRule(css, "n2").includes("visibility")); }); }); // Hidden-node banded geometry. A `visibility:hidden` box still PARTICIPATES in layout (unlike // `display:none`), so the emitter must not let the base rule's baked CANONICAL geometry stand at // widths where the capture measured something else — that is how a desktop `left:548px` slider // arrow ends up parked, invisibly, 210px past the right edge of a 375px viewport. describe("generateCss hidden-node banded geometry", () => { const HVPS = [375, 1280, 1920]; type VpState = { cs?: StyleMap; bbox?: BBox; visible?: boolean }; function nodeAt(id: string, tag: string, byVp: Record, children: IRChild[] = []): IRNode { const computedByVp: Record = {}; const bboxByVp: Record = {}; const visibleByVp: Record = {}; for (const vp of HVPS) { const s = byVp[vp] ?? {}; computedByVp[vp] = computed(s.cs); bboxByVp[vp] = s.bbox ?? { x: 0, y: 0, width: vp, height: 100 }; visibleByVp[vp] = s.visible ?? true; } return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children }; } function ir3(root: IRNode): IR { const ir = irWith(root); ir.doc.viewports = HVPS; ir.doc.sampleViewports = HVPS; ir.doc.perViewport = Object.fromEntries(HVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])); return ir; } /** The `.c{…}` body inside the first @media block whose query matches `mediaRe`. */ function bandRule(css: string, mediaRe: RegExp, id: string): string { for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) { if (!mediaRe.test(m[1]!)) continue; const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`)); if (r) return r[1]!; } return ""; } const arrowCs = (left: string, hidden: boolean): StyleMap => ({ display: "flex", position: "absolute", left, ...(hidden ? { visibility: "hidden" } : {}) }); it("emits the captured per-band geometry for a box its own visibility:hidden leaves occupying space", () => { // Hidden at base AND at the mobile band with DIFFERENT lefts (the swiper-arrow shape): the // band must carry the mobile left, not inherit the baked canonical one. const arrow = nodeAt("n1", "div", { 375: { cs: arrowCs("37px", true), bbox: { x: 37, y: 0, width: 46, height: 46 }, visible: false }, 1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false }, 1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } }, }); const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]); const css = generateCss(ir3(root), new Map()); assert.ok(baseRule(css, "n1").includes("visibility:hidden")); assert.ok(baseRule(css, "n1").includes("left:548px")); const mobile = bandRule(css, /max-width/, "n1"); assert.ok(mobile.includes("left:37px"), `mobile band should carry the captured left, got: ${mobile}`); assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout"); // The wide band where the node becomes visible keeps working: visibility restored + its left. const wide = bandRule(css, /min-width/, "n1"); assert.ok(wide.includes("visibility:inherit"), `wide band should restore visibility, got: ${wide}`); assert.ok(wide.includes("left:588px"), `wide band should carry the 1920 left, got: ${wide}`); }); it("hides a visibility:hidden box whose captured bbox is 0x0 with display:none at that band", () => { // At 375 the hidden arrow occupied NOTHING (uninitialised swiper) — display:none reproduces // "renders nothing, takes no space" and cannot extend the scrollable area. const arrow = nodeAt("n1", "div", { 375: { cs: arrowCs("calc(50% - 52px)", true), bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false }, 1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false }, 1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } }, }); const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]); const css = generateCss(ir3(root), new Map()); const mobile = bandRule(css, /max-width/, "n1"); assert.ok(mobile.includes("display:none"), `0x0 hidden band should be display:none, got: ${mobile}`); const wide = bandRule(css, /min-width/, "n1"); assert.ok(wide.includes("visibility:inherit") && wide.includes("left:588px")); }); it("emits display:none at a band where the node turns display:none even when hidden at base", () => { // The base rule bakes an OCCUPYING visibility:hidden box (canonical geometry); without the // band that box would render at mobile widths where the source had display:none. const wrap = nodeAt("n1", "div", { 375: { cs: { display: "none", visibility: "hidden" }, bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false }, 1280: { cs: { visibility: "hidden" }, bbox: { x: 40, y: 0, width: 1200, height: 574 }, visible: false }, 1920: { cs: {}, bbox: { x: 320, y: 0, width: 1280, height: 533 } }, }); const root = nodeAt("n0", "body", {}, [wrap]); const css = generateCss(ir3(root), new Map()); assert.ok(baseRule(css, "n1").includes("visibility:hidden")); const mobile = bandRule(css, /max-width/, "n1"); assert.ok(mobile.includes("display:none"), `own display:none band must emit even when hidden at base, got: ${mobile}`); }); it("emits per-band geometry for an occupying box an ancestor hides at base AND at the band", () => { // The cropin.com/cotton slider arrow: the elementor-widget ANCESTOR is visibility:hidden at // 375/1280 (so the arrow is never ownHidden and never shownAtBase) yet the arrow's absolute // box still occupies layout. Without a band the base's canonical left:548px parks it 210px // past the right edge of a 375px viewport — the band must carry the captured mobile left. const arrow = nodeAt("n2", "div", { 375: { cs: arrowCs("120px", true), bbox: { x: 112, y: 0, width: 46, height: 46 }, visible: false }, 1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false }, 1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } }, }); const parent = nodeAt("n1", "div", { 375: { cs: { position: "relative", visibility: "hidden" }, visible: false }, 1280: { cs: { position: "relative", visibility: "hidden" }, visible: false }, 1920: { cs: { position: "relative" } }, }, [arrow]); const root = nodeAt("n0", "body", {}, [parent]); const css = generateCss(ir3(root), new Map()); const mobile = bandRule(css, /max-width/, "n2"); assert.ok(mobile.includes("left:120px"), `mobile band should carry the captured left, got: ${mobile}`); assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout"); }); it("still emits only the hide for a box an ANCESTOR's visibility:hidden covers", () => { // Inherited hides stay breakpoint noise: the ancestor's own rule (and its geometry // correction) covers the subtree — the child emits its hide, not geometry overrides. const child = nodeAt("n2", "div", { 375: { cs: { position: "absolute", left: "10px", visibility: "hidden" }, bbox: { x: 10, y: 0, width: 40, height: 40 }, visible: false }, 1280: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } }, 1920: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } }, }); const parent = nodeAt("n1", "div", { 375: { cs: { position: "relative", visibility: "hidden" }, visible: false }, 1280: { cs: { position: "relative" } }, 1920: { cs: { position: "relative" } }, }, [child]); const root = nodeAt("n0", "body", {}, [parent]); const css = generateCss(ir3(root), new Map()); const mobile = bandRule(css, /max-width/, "n2"); assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`); assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`); }); it("emits per-band geometry for an occupying slide off-viewport at EVERY width, base included", () => { // A carousel slide 6–12: a shrink-0 flex item off-screen RIGHT at every captured width — 375, // the 1280 canonical base, AND 1920 — so it is never ownHidden, never ancestor-hidden, and never // visibleByVp[base]=true (the walker marks `bbox.x >= vpW` invisible). It is NOT suppressed // (no opacity:0 / visibility:hidden) and stays IN FLOW, so it still sets its flex track's // cross-size. Without a band the base's canonical desktop width freezes across every viewport and // inflates the mobile track. The band must carry THIS width's measured geometry. const slide = nodeAt("n2", "div", { 375: { cs: { display: "block", position: "static", opacity: "1", width: "220px" }, bbox: { x: 400, y: 0, width: 220, height: 432 }, visible: false }, 1280: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 1500, y: 0, width: 285, height: 532 }, visible: false }, 1920: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 2200, y: 0, width: 285, height: 532 }, visible: false }, }); const track = nodeAt("n1", "div", { 375: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 375, height: 432 } }, 1280: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1280, height: 532 } }, 1920: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1920, height: 532 } }, }, [slide]); const root = nodeAt("n0", "body", {}, [track]); const css = generateCss(ir3(root), new Map()); assert.ok(baseRule(css, "n2").includes("width:285px"), `base bakes canonical width, got: ${baseRule(css, "n2")}`); const mobile = bandRule(css, /max-width/, "n2"); assert.ok(mobile.includes("width:220px"), `mobile band must carry the measured 220px width, got: ${mobile}`); assert.ok(!mobile.includes("display:none"), "an occupying off-viewport slide must stay in layout"); }); }); // Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto // flex