diff --git a/compiler/src/capture/graft.ts b/compiler/src/capture/graft.ts index 88b1406..746f0d8 100644 --- a/compiler/src/capture/graft.ts +++ b/compiler/src/capture/graft.ts @@ -171,6 +171,21 @@ export function graftFrameIntoSnapshot( x: Math.round((n.bbox.x + frame.contentX) * 100) / 100, y: Math.round((n.bbox.y + frame.contentY) * 100) / 100, }; + // Rebase viewport-relative positioning into the frame's box. Inside a real iframe, + // `position: fixed` pins to the FRAME's viewport (its content box) and `sticky` scrolls + // against the FRAME's scroll container. Grafted in as plain
children, those + // containing blocks no longer exist, so `fixed` would resolve against the MAIN page + // viewport — escaping the iframe's clip box (fixed ignores `overflow:hidden` ancestors) + // and painting frame chrome (e.g. a `fixed inset-0 z-[-1]` dark backdrop) across the + // whole clone. Demote to box-relative positioning: `fixed`→`absolute` (contained and + // clipped by the host div, whose bboxes are already rebased to page coords) and + // `sticky`→`relative` (stays in flow instead of sticking to the page scroll). The host + // is made a containing block below so these absolutes anchor to the frame box. + if (n.computed) { + const pos = n.computed.position; + if (pos === "fixed") n.computed = { ...n.computed, position: "absolute" }; + else if (pos === "sticky") n.computed = { ...n.computed, position: "relative" }; + } const a = n.attrs ?? {}; delete a["data-cid-cap"]; // capture-ids belong to the main document only if (a.id) a.id = prefix + a.id; @@ -190,8 +205,13 @@ export function graftFrameIntoSnapshot( host.children = [wrapper]; // A real frame viewport clips its document; the replacement
must too, at every - // captured viewport (each per-viewport snapshot is grafted independently). + // captured viewport (each per-viewport snapshot is grafted independently). It must also + // be a containing block: `visit` demoted the frame's `position: fixed` chrome (which was + // pinned to the frame viewport) to `absolute`, and those absolutes must anchor to — and + // be clipped by — this box, not some positioned ancestor further up the page. A `static` + // host wouldn't establish that containing block, so promote it to `relative`. host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" }; + if ((host.computed.position || "static") === "static") host.computed.position = "relative"; // An iframe is a REPLACED element: `display:inline` (the default) still honors its // width/height. The
that replaces it at generation is not — inline would collapse // the box — so translate to the behavior-equivalent inline-block. diff --git a/compiler/test/iframeGraft.test.ts b/compiler/test/iframeGraft.test.ts index 85f7b37..dc5cbe2 100644 --- a/compiler/test/iframeGraft.test.ts +++ b/compiler/test/iframeGraft.test.ts @@ -163,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => { graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame); assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }")); }); + + // A frame's `position: fixed` chrome pins to the FRAME viewport, not the page's. Grafted + // as plain DOM, `fixed` would resolve against the main page viewport and escape the + // iframe's clip box — the observed regression: a gallery embed's + // `fixed inset-0 z-[-1]` dark backdrop painted the whole clone dark. It must be demoted + // to `absolute` so the host box contains and clips it. + it("demotes frame `position: fixed` to `absolute` so it stays inside the iframe box", () => { + const backdrop = raw("div", { class: "backdrop" }, [], { + computed: { display: "block", position: "fixed" }, + bbox: { x: 0, y: 0, width: 320, height: 160 }, + }); + const frame = pageSnap(raw("body", {}, [backdrop]), "https://frame.test/embed"); + const host = mkHost(); + graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame); + const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode; + const grafted = wrapper.children[0] as RawNode; + assert.equal(grafted.computed.position, "absolute"); + }); + + it("demotes frame `position: sticky` to `relative` (no sticking to the page scroll)", () => { + const bar = raw("div", { class: "sticky-bar" }, [], { + computed: { display: "block", position: "sticky" }, + bbox: { x: 0, y: 0, width: 320, height: 40 }, + }); + const frame = pageSnap(raw("body", {}, [bar]), "https://frame.test/embed"); + const host = mkHost(); + graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame); + const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode; + const grafted = wrapper.children[0] as RawNode; + assert.equal(grafted.computed.position, "relative"); + }); + + it("promotes a `static` host to `relative` so demoted absolutes anchor to the frame box", () => { + const host = mkHost(); // iframe host computed.position defaults to "static" + graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame()); + assert.equal(findFrameNode(host.root, 0)!.computed.position, "relative"); + }); + + it("leaves an already-positioned host's position untouched", () => { + const host = pageSnap(raw("body", {}, [ + raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], { + computed: { display: "block", position: "absolute" }, + bbox: { x: 40, y: 30, width: 320, height: 160 }, + }), + ])); + graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame()); + assert.equal(findFrameNode(host.root, 0)!.computed.position, "absolute"); + }); }); // ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----