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 ----