From 9e3ff630f4b6bccd3995b64800429bde0078a841 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 13:04:35 -0700 Subject: [PATCH 1/2] fix(shader-transitions): pin cloned scene roots to composition dimensions --- .../shader-transitions/src/engineModePageComposite.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index ab9445a896..c23b8ac2ba 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -247,6 +247,17 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) for (const clone of [fromClone, toClone]) { clone.style.opacity = "1"; clone.style.visibility = "visible"; + // A scene root sized only by `position:absolute; inset:0` resolves to + // 0x0 inside the staging canvas's layout subtree (no containing-block + // dimensions there) — the clone then paints nothing and the transition + // textures are blank (wild report: explicit 1080x1920 anchors fixed + // both transitions). Pin the clone to the composition's pixel size; + // scenes that already carry explicit dimensions are unaffected. + clone.style.position = "absolute"; + clone.style.left = "0"; + clone.style.top = "0"; + clone.style.width = `${width}px`; + clone.style.height = `${height}px`; clone.querySelectorAll("[data-start]").forEach((el) => { el.style.opacity = "1"; el.style.visibility = "visible"; From b3d851d65af7e2d37e50b93983c6e721532224f9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 13:40:51 -0700 Subject: [PATCH 2/2] fix(shader-transitions): pin clones to their measured live box, not composition size Addresses review feedback on #2254: the previous fix hardcoded every clone to the full composition width/height regardless of the source scene's own sizing, silently overriding any authored explicit width/height. Measuring each scene's getBoundingClientRect() while still live in the document (its inset:0 already resolves correctly against the real ancestor chain there) and reapplying that exact box to the clone fixes the 0x0 collapse without ever clobbering an author's own layout. --- .../src/engineModePageComposite.test.ts | 21 +++++++ .../src/engineModePageComposite.ts | 60 +++++++++++++++---- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/shader-transitions/src/engineModePageComposite.test.ts b/packages/shader-transitions/src/engineModePageComposite.test.ts index f812db7422..4b6a6b1d50 100644 --- a/packages/shader-transitions/src/engineModePageComposite.test.ts +++ b/packages/shader-transitions/src/engineModePageComposite.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + clonePinStyleFor, isPageSideCompositingSupported, PAGE_COMPOSITOR_BUILD_CANARY, PAGE_COMPOSITOR_CANVAS_ID, @@ -69,6 +70,26 @@ describe("isPageSideCompositingSupported", () => { }); }); +describe("clonePinStyleFor", () => { + it("fixes a 0x0 inset:0 scene root to its live-measured box (the collapse this exists to prevent)", () => { + // A scene root sized only by `position:absolute; inset:0` measures as + // the full composition frame in the live document (its containing block + // there is the real ancestor chain) — collapses to 0x0 only once cloned + // into the staging canvas's own layout subtree. + const pin = clonePinStyleFor({ left: 0, top: 0, width: 1080, height: 1920 }); + expect(pin).toEqual({ left: "0px", top: "0px", width: "1080px", height: "1920px" }); + }); + + it("preserves an authored explicit width/height and offset instead of overriding it", () => { + // A scene root with its own explicit size/position (e.g. a picture-in- + // picture panel) measures as that exact box in the live document — + // clonePinStyleFor must reproduce it verbatim, not the full composition + // frame, or the clone would silently grow to fill the canvas. + const pin = clonePinStyleFor({ left: 120, top: 240, width: 400, height: 300 }); + expect(pin).toEqual({ left: "120px", top: "240px", width: "400px", height: "300px" }); + }); +}); + describe("page-side compositor exported constants", () => { it("exports a stable canary string used by the bundled-CLI smoke", () => { expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__"); diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index c23b8ac2ba..409c5f52f0 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -76,6 +76,36 @@ interface ResolvedTransition { export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor"; export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__"; +export interface ClonePinStyle { + left: string; + top: string; + width: string; + height: string; +} + +/** + * Style values to pin a cloned scene root to the box its source measured + * WHILE STILL LIVE in the document — never the composition's full pixel size. + * A live-document `getBoundingClientRect()` already resolves `inset:0` (and + * any authored explicit width/height) correctly against the real ancestor + * chain; reapplying that exact box to the clone fixes the 0x0 collapse a + * detached `inset:0` clone would otherwise have inside the staging canvas's + * layout subtree, without ever overriding an author's own sizing. + */ +export function clonePinStyleFor(rect: { + left: number; + top: number; + width: number; + height: number; +}): ClonePinStyle { + return { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }; +} + export function isPageSideCompositingSupported(): boolean { if (typeof window === "undefined" || typeof document === "undefined") return false; if (!isHtmlInCanvasCaptureSupported()) return false; @@ -232,6 +262,17 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) pWin.__hf_page_composite_pending = false; return false; } + // Measure each scene's rendered box WHILE STILL LIVE — a scene root sized + // only by `position:absolute; inset:0` resolves to 0x0 once cloned into + // the staging canvas's layout subtree (no containing-block dimensions + // there), and the transition textures blank out (wild report: explicit + // 1080x1920 anchors fixed both transitions). The live document already + // resolves inset:0 (and any authored explicit width/height) correctly + // against the real ancestor chain, so pinning the clone to THIS measured + // box fixes the collapse without ever overriding an author's own sizing. + const fromPin = clonePinStyleFor(fromEl.getBoundingClientRect()); + const toPin = clonePinStyleFor(toEl.getBoundingClientRect()); + while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild); while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild); const fromClone = fromEl.cloneNode(true) as HTMLElement; @@ -244,20 +285,17 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) // paint record" and the shader degrades to a hard cut. The shader blends from // full-opacity textures via u_progress, so force the clones visible. Cf. // forceSceneVisibleInClone (html2canvas path). - for (const clone of [fromClone, toClone]) { + for (const [clone, pin] of [ + [fromClone, fromPin], + [toClone, toPin], + ] as const) { clone.style.opacity = "1"; clone.style.visibility = "visible"; - // A scene root sized only by `position:absolute; inset:0` resolves to - // 0x0 inside the staging canvas's layout subtree (no containing-block - // dimensions there) — the clone then paints nothing and the transition - // textures are blank (wild report: explicit 1080x1920 anchors fixed - // both transitions). Pin the clone to the composition's pixel size; - // scenes that already carry explicit dimensions are unaffected. clone.style.position = "absolute"; - clone.style.left = "0"; - clone.style.top = "0"; - clone.style.width = `${width}px`; - clone.style.height = `${height}px`; + clone.style.left = pin.left; + clone.style.top = pin.top; + clone.style.width = pin.width; + clone.style.height = pin.height; clone.querySelectorAll("[data-start]").forEach((el) => { el.style.opacity = "1"; el.style.visibility = "visible";