fix(studio): stop tween re-inits from baking runtime opacity transients

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
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-11 03:34:21 -04:00
parent 3147c8e063
commit 5cc14c2221
7 changed files with 343 additions and 75 deletions
@@ -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<string, string>([["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" };
+35 -1
View File
@@ -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;
@@ -38,6 +38,19 @@ function result(over: Partial<MutationResult> = {}): 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<MutationResult> = {}): 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();
});
});
@@ -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();
}
@@ -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);
}
+31
View File
@@ -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<string, number>) => 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;
}
+56 -4
View File
@@ -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<string, { kill?: () => 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");
}
}
}