From 5cc14c2221bb6262ddd0631c9f2df1891f266217 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 11 Jul 2026 03:34:21 -0400 Subject: [PATCH] fix(studio): stop tween re-inits from baking runtime opacity transients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing commits made elements vanish or dim permanently: invalidating the whole timeline (or re-running the composition script on soft reload) made GSAP re-capture tween bounds while runtime transients were live — the grading hide's opacity 0, or a mid-flight tween value — so from()/to() bounds got poisoned and the element rendered invisible from then on. - patch only the edited tween in place, never timeline.invalidate() - soft reload restores every animated element's authored inline opacity (after-write HTML first, parse-time stamp as fallback) before the script re-runs and re-captures - a paired x/y commit whose second half is a no-op (changed=false) still applies its instant patch, so panel edits reflect without deselecting --- .../studio/src/hooks/gsapRuntimePatch.test.ts | 63 +++++++ packages/studio/src/hooks/gsapRuntimePatch.ts | 36 +++- .../src/hooks/useGsapScriptCommits.test.tsx | 167 +++++++++++------- .../studio/src/hooks/useGsapScriptCommits.ts | 33 +++- packages/studio/src/utils/authoredOpacity.ts | 28 +++ packages/studio/src/utils/elementGsap.ts | 31 ++++ packages/studio/src/utils/gsapSoftReload.ts | 60 ++++++- 7 files changed, 343 insertions(+), 75 deletions(-) create mode 100644 packages/studio/src/utils/authoredOpacity.ts create mode 100644 packages/studio/src/utils/elementGsap.ts diff --git a/packages/studio/src/hooks/gsapRuntimePatch.test.ts b/packages/studio/src/hooks/gsapRuntimePatch.test.ts index bf999f9c8..1a17f7e06 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.test.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.test.ts @@ -133,6 +133,69 @@ describe("patchRuntimeTweenInPlace — set tweens", () => { }); }); +describe("patchRuntimeTweenInPlace — authored-opacity capture guard", () => { + function makeStampedEl(id: string, stamped: string | null, inlineOpacity: string) { + const style = new Map([["opacity", inlineOpacity]]); + return { + el: { + id, + style: { + setProperty: (k: string, v: string) => void style.set(k, v), + removeProperty: (k: string) => void style.delete(k), + }, + getAttribute: (name: string) => (name === "data-hf-authored-opacity" ? stamped : null), + }, + style, + }; + } + + it("restores the stamped authored opacity before an opacity-touching patch", () => { + // Runtime transient (grading hide / mid-flight tween) baked into inline style. + const { el, style } = makeStampedEl("box", "0.75", "0"); + const setTween = makeTween( + { vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 }, + el, + ); + const { iframe } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#box", { + kind: "set", + props: { opacity: 0.5 }, + }); + + expect(ok).toBe(true); + // The re-init must capture the authored 0.75, not the transient 0. + expect(style.get("opacity")).toBe("0.75"); + expect(setTween.vars.opacity).toBe(0.5); + }); + + it("removes inline opacity when the stamp recorded no authored value", () => { + const { el, style } = makeStampedEl("box", "", "0"); + const setTween = makeTween( + { vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 }, + el, + ); + const { iframe } = fakeIframe(el, [setTween]); + + patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { opacity: 0.5 } }); + + expect(style.has("opacity")).toBe(false); + }); + + it("leaves inline opacity alone for a position-only patch", () => { + const { el, style } = makeStampedEl("box", "0.75", "0"); + const setTween = makeTween( + { vars: { x: 0, y: 0, duration: 0 }, targetIds: ["box"], duration: 0 }, + el, + ); + const { iframe } = fakeIframe(el, [setTween]); + + patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { x: 10, y: 20 } }); + + expect(style.get("opacity")).toBe("0"); + }); +}); + describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => { it("patches the {x,y} set, not a co-located rotation-only set", () => { const el = { id: "dual" }; diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index a95f248f9..de2c6480e 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -13,6 +13,7 @@ * "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes` * uses (`resolveRuntimeTween`), so read and write agree on the target. */ +import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity"; import { resolveRuntimeTween, type RuntimeTween, @@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0); } +/** Does this change touch the opacity channel (whose re-init reads inline style)? */ +function changeTouchesOpacity(change: RuntimeTweenChange): boolean { + if (change.kind === "set" || change.kind === "global-set") + return change.props.opacity !== undefined; + if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step); + return "opacity" in change.props; +} + +/** + * A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild) + * captures opacity from the element's CURRENT inline style — for a color-graded + * source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a + * runtime transient, not the authored value, and the capture makes it permanent. + * Restore the runtime's parse-time authored capture (data-hf-authored-opacity) + * first; the re-seek after the patch re-renders the animated value anyway. + * Duck-typed (no instanceof): the targets live in the preview iframe's realm. + */ +function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void { + const targets = typeof tween.targets === "function" ? tween.targets() : []; + for (const target of targets ?? []) { + const el = target as HTMLElement | null; + if (!el?.style || typeof el.getAttribute !== "function") continue; + const authored = readStampedAuthoredOpacity(el); + if (authored === null) continue; + applyAuthoredInlineOpacity(el.style, authored); + } +} + /** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload. * `global-set` is handled before this (no tween) and never reaches here. */ function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean { @@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace( if (!resolved) return false; const { tween, timeline } = resolved; + if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween); if (!applyChange(tween, change)) return false; // A rebuild already recreated the tween; set/keyframes mutate vars in place, so // invalidate to make GSAP re-read them on the next render. Either way, re-seek. + // Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide + // invalidate re-initializes every from() tween against the CURRENT inline + // styles, and the color-grading engine hides its source elements with + // `opacity: 0 !important` — so every graded element's from(opacity) re-captures + // 0 as its end value and animates 0→0 forever (all graded elements vanish). if (change.kind !== "keyframe-rebuild") { tween.invalidate?.(); - timeline.invalidate?.(); } seekToCurrent(iframe, timeline); return true; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index ee192997f..8416a4a08 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -38,6 +38,19 @@ function result(over: Partial = {}): MutationResult { return { ok: true, scriptText: "tl.set('#a',{})", ...over }; } +/** The canonical drag commit options every path-decision test drives with. */ +function dragOptions() { + return { + label: "drag", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } }, + }; +} + +function syncDragPreview(res: MutationResult, reloadPreview: () => void) { + applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview); +} + describe("applyPreviewSync", () => { beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); @@ -49,16 +62,7 @@ describe("applyPreviewSync", () => { patchRuntimeTweenInPlace.mockReturnValue(true); const reloadPreview = vi.fn(); - applyPreviewSync( - FAKE_IFRAME, - result(), - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - reloadPreview, - ); + syncDragPreview(result(), reloadPreview); expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", { kind: "set", @@ -73,20 +77,17 @@ describe("applyPreviewSync", () => { applySoftReload.mockReturnValue("applied"); const reloadPreview = vi.fn(); - applyPreviewSync( - FAKE_IFRAME, - result({ scriptText: "SCRIPT" }), - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - reloadPreview, - ); + syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview); // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin // CDN load failure escalates to a full reload — but it is NOT called eagerly. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).not.toHaveBeenCalled(); // A successful instant patch is the fast path; here it missed → fallback event. expect(trackStudioEvent).toHaveBeenCalledWith( @@ -100,20 +101,17 @@ describe("applyPreviewSync", () => { applySoftReload.mockReturnValue("verify-failed"); const reloadPreview = vi.fn(); - applyPreviewSync( - FAKE_IFRAME, - result({ scriptText: "SCRIPT" }), - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - reloadPreview, - ); + syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview); // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state // is correct, so we must NOT escalate to a full reload. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).not.toHaveBeenCalled(); // Telemetry records the suppressed transient (escalated: false). expect(trackStudioEvent).toHaveBeenCalledWith( @@ -131,19 +129,16 @@ describe("applyPreviewSync", () => { applySoftReload.mockReturnValue("cannot-soft-reload"); const reloadPreview = vi.fn(); - applyPreviewSync( - FAKE_IFRAME, - result({ scriptText: "SCRIPT" }), - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - reloadPreview, - ); + syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview); // Structural failure: the preview is genuinely stale/broken → full reload. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).toHaveBeenCalledTimes(1); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -167,7 +162,13 @@ describe("applyPreviewSync", () => { ); expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).not.toHaveBeenCalled(); // "applied" emits no telemetry (only the failure paths do). expect(trackStudioEvent).not.toHaveBeenCalled(); @@ -185,7 +186,13 @@ describe("applyPreviewSync", () => { ); // onAsyncFailure is wired, but the transient result does not trigger it. - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).not.toHaveBeenCalled(); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -204,7 +211,13 @@ describe("applyPreviewSync", () => { reloadPreview, ); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + reloadPreview, + 0, + undefined, + ); expect(reloadPreview).toHaveBeenCalledTimes(1); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", @@ -291,6 +304,32 @@ function mockFetchResult(over: Partial = {}): void { } describe("runCommit — instantPatch wiring", () => { + it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => { + patchRuntimeTweenInPlace.mockReturnValue(true); + mockFetchResult({ changed: false }); + const deps = renderCommitHook(); + + await act(async () => { + await deps.api.commitMutation( + selection, + { type: "update-property", property: "y", value: 311 }, + { + label: "Move layer", + softReload: true, + instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } }, + }, + ); + }); + + // The file already matched (changed:false) but the runtime patch deferred + // from the paired first commit must still land. + expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", { + kind: "set", + props: { x: 485, y: 311 }, + }); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); applySoftReload.mockReset(); @@ -308,15 +347,7 @@ describe("runCommit — instantPatch wiring", () => { const deps = renderCommitHook(); await act(async () => { - await deps.api.commitMutation( - selection, - { x: 10 }, - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - ); + await deps.api.commitMutation(selection, { x: 10 }, dragOptions()); }); expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted @@ -333,19 +364,17 @@ describe("runCommit — instantPatch wiring", () => { const deps = renderCommitHook(); await act(async () => { - await deps.api.commitMutation( - selection, - { x: 10 }, - { - label: "drag", - softReload: true, - instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } }, - }, - ); + await deps.api.commitMutation(selection, { x: 10 }, dragOptions()); }); expect(fetch).toHaveBeenCalledTimes(1); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + deps.reloadPreview, + 0, + "AFTER", + ); expect(deps.reloadPreview).not.toHaveBeenCalled(); expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1); }); @@ -360,7 +389,13 @@ describe("runCommit — instantPatch wiring", () => { }); expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); - expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0); + expect(applySoftReload).toHaveBeenCalledWith( + FAKE_IFRAME, + "SCRIPT", + deps.reloadPreview, + 0, + "AFTER", + ); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index e490f6ee5..a28478acb 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -66,12 +66,19 @@ function softReloadOrEscalate( scriptText: string, reloadPreview: () => void, origin: "preview_sync" | "sdk_refresh", + authoredHtml?: string, ): void { // Seek the rebuilt timeline to the studio's own authoritative scrub position, // not the iframe's raw `__player.getTime()` — see the comment in // applySoftReload for why the two can desync after a keyframe-node drag. const currentTime = usePlayerStore.getState().currentTime; - const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime); + const result: SoftReloadResult = applySoftReload( + iframe, + scriptText, + reloadPreview, + currentTime, + authoredHtml, + ); if (result === "applied") return; trackStudioEvent("gsap_soft_reload_outcome", { origin, @@ -116,7 +123,13 @@ export function applyPreviewSync( // already correct on screen, and a remount re-flashes the WebGL context AND // re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin // load failure escalates separately via `onAsyncFailure`. - softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync"); + softReloadOrEscalate( + iframe, + result.scriptText, + reloadPreview, + "preview_sync", + result.after ?? undefined, + ); } else { reloadPreview(); } @@ -149,7 +162,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra if (options.skipReload) return; throw error; } - if (result.changed === false) return; + if (result.changed === false) { + // The FILE already matched, but a deferred instant patch may still be + // owed to the RUNTIME: paired commits (x with skipReload, then y carrying + // the patch for both) rely on the SECOND commit to sync the preview — if + // that half happens to be a no-op (a purely-horizontal drag or resize + // compensation), returning here would leave the runtime showing the old + // value while the file holds the new one. Patching in place is idempotent + // when the values truly match everywhere. + if (!options.skipReload && options.instantPatch) { + applyPreviewSync(previewIframeRef.current, result, options, reloadPreview); + } + return; + } domEditSaveTimestampRef.current = Date.now(); if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); @@ -196,7 +221,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra // plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a // synchronous "verify-failed" (transient empty __timelines) does NOT escalate, // but a "cannot-soft-reload" (structural failure) does. - softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh"); + softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after); } else { reloadPreview(); } diff --git a/packages/studio/src/utils/authoredOpacity.ts b/packages/studio/src/utils/authoredOpacity.ts new file mode 100644 index 000000000..6a6b4998b --- /dev/null +++ b/packages/studio/src/utils/authoredOpacity.ts @@ -0,0 +1,28 @@ +/** + * Authored-opacity contract, studio side. The runtime stamps every graded + * element's authored inline opacity at document parse time (see + * installAuthoredOpacityCapture in @hyperframes/core); studio code that makes + * GSAP re-initialize tweens (soft reload, in-place patches) restores it so + * re-captures never bake a runtime transient in as a tween bound. + */ +import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading"; + +interface AttributeReader { + getAttribute(name: string): string | null; +} + +/** + * The stamped authored inline opacity. Three-state: + * "0.98" — the authored value; "" — captured, authored none; + * null — never captured (unknown). + * Duck-typed so iframe-realm elements (no shared HTMLElement) work. + */ +export function readStampedAuthoredOpacity(element: AttributeReader): string | null { + return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR); +} + +/** Write an authored inline opacity back: "" removes the property, a value sets it. */ +export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void { + if (authored === "") style.removeProperty("opacity"); + else style.setProperty("opacity", authored); +} diff --git a/packages/studio/src/utils/elementGsap.ts b/packages/studio/src/utils/elementGsap.ts new file mode 100644 index 000000000..e89b6f986 --- /dev/null +++ b/packages/studio/src/utils/elementGsap.ts @@ -0,0 +1,31 @@ +/** + * GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime), + * not the studio window. This is the single way studio gesture code touches an + * iframe element's GSAP position outside the commit pipeline — the resize + * anchor pin (apply + restore) and the post-commit live correction. The commit + * pipeline itself stays the owner of persisted values. + */ +type ElementGsapWindow = Window & { + gsap?: { + set?: (target: Element, vars: Record) => void; + getProperty?: (target: Element, prop: string) => unknown; + }; +}; + +function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined { + return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap; +} + +/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */ +export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean { + const gsap = gsapOf(element); + if (!gsap?.set) return false; + gsap.set(element, { x, y }); + return true; +} + +/** The element's GSAP numeric property, or null when unreadable. */ +export function readElementGsapNumber(element: HTMLElement, prop: string): number | null { + const value = Number(gsapOf(element)?.getProperty?.(element, prop)); + return Number.isFinite(value) ? value : null; +} diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 1cea9b789..a5f08eea3 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -1,3 +1,6 @@ +import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; +import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity"; + type IframeWindow = Window & { __timelines?: Record void; pause?: () => void }>; __player?: { getTime?: () => number; seek?: (t: number) => void }; @@ -176,6 +179,7 @@ export function applySoftReload( scriptText: string, onAsyncFailure?: () => void, currentTimeOverride?: number, + authoredHtml?: string, ): SoftReloadResult { if (!iframe || !scriptText) return "cannot-soft-reload"; @@ -227,6 +231,36 @@ export function applySoftReload( // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; + // Authored-opacity resolution for the restore loop below. Three-state: + // "0.98" — the element's authored inline opacity + // "" — resolved, and the element has NO authored inline opacity + // null — unknown (no authored HTML supplied, element not found in it, + // and no runtime parse-time stamp) + // The just-written file (`authoredHtml`) is the current truth; the runtime's + // parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture) + // covers elements the file lookup can't resolve. Parsed lazily, at most once. + let authoredDoc: Document | null | undefined; + const findAuthoredSource = (el: HTMLElement): Element | null => { + if (authoredDoc === undefined) { + try { + authoredDoc = authoredHtml + ? new DOMParser().parseFromString(authoredHtml, "text/html") + : null; + } catch { + authoredDoc = null; + } + } + if (!authoredDoc) return null; + const hfId = el.getAttribute("data-hf-id"); + if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`); + return el.id ? authoredDoc.getElementById(el.id) : null; + }; + const readAuthoredOpacity = (el: HTMLElement): string | null => { + const source = findAuthoredSource(el); + if (source instanceof HTMLElement) return source.style.opacity; + return readStampedAuthoredOpacity(el); + }; + // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; @@ -283,19 +317,37 @@ export function applySoftReload( // nukes the element's CSS base (position, width, height, etc.) from the // HTML `style=""` attribute. Save → clear → restore → strip `transform`. if (allTargets.length > 0 && win.gsap?.set) { - const saved: Array<[Element, string]> = []; + const saved: Array<[HTMLElement, string]> = []; for (const el of allTargets) { const s = (el as HTMLElement).style; - if (s?.cssText != null) saved.push([el, s.cssText]); + if (s?.cssText != null) saved.push([el as HTMLElement, s.cssText]); } try { win.gsap.set(allTargets, { clearProps: "all" }); } catch {} for (const [el, css] of saved) { - const s = (el as HTMLElement).style; - if (!s) continue; + const s = el.style; s.cssText = css; s.removeProperty("transform"); + // The restored cssText carries RUNTIME opacity, not authored opacity: + // a mid-flight tween's interpolated value, or the color-grading hide + // (`opacity: 0 !important`). The re-run script's tweens re-initialize + // against it — a from() captures it as its END, a to() as its START — + // turning the transient into the tween's permanent bound (dimmed or + // invisible elements). Put the AUTHORED inline opacity back; the seek + // below re-renders the correct animated value either way. + const authored = readAuthoredOpacity(el); + if (authored !== null) { + applyAuthoredInlineOpacity(s, authored); + } else if ( + el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) && + s.getPropertyValue("opacity") === "0" && + s.getPropertyPriority("opacity") === "important" + ) { + // Authored value unknown, but this is definitely the grading hide — + // never let a from() capture 0; fall back to the CSS cascade. + s.removeProperty("opacity"); + } } }