From 200890ac61e04713e04d741874016e2b57299da6 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 2 Aug 2026 18:24:57 -0400 Subject: [PATCH] fix(desktop): keep terminal fade compositor-stable Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../features/terminal/fadeController.test.mjs | 53 +++++++++++++++++-- .../src/features/terminal/fadeController.ts | 8 +-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/terminal/fadeController.test.mjs b/desktop/src/features/terminal/fadeController.test.mjs index debbdd745e..9a8c28b1a5 100644 --- a/desktop/src/features/terminal/fadeController.test.mjs +++ b/desktop/src/features/terminal/fadeController.test.mjs @@ -8,20 +8,30 @@ import { REVEAL_DURATION_MS, } from "./fadeController.ts"; +function deferred() { + let resolve; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + function fixture() { const calls = []; - let resolveFinished; + const completions = [deferred()]; const animation = { currentTime: 0, playbackRate: 0, - finished: new Promise((resolve) => { - resolveFinished = resolve; - }), + finished: completions[0].promise, play() { calls.push("play"); + completions.push(deferred()); + this.finished = completions.at(-1).promise; }, reverse() { calls.push("reverse"); + completions.push(deferred()); + this.finished = completions.at(-1).promise; }, cancel() { calls.push("cancel"); @@ -37,8 +47,8 @@ function fixture() { return { animation, calls, + completions, controller: new FadeController(surface), - resolveFinished, surface, }; } @@ -56,6 +66,39 @@ test("reversal reuses the one timeline and scales return playback", () => { assert.equal(calls[0].options.easing, FADE_EASING); }); +test("fade durations are half the original timing", () => { + assert.equal(REVEAL_DURATION_MS, 360); + assert.equal(CONCEAL_DURATION_MS, 210); +}); + +test("completed fades retain the timeline but release its compositor hint", async () => { + const { calls, completions, controller, surface } = fixture(); + + assert.equal(controller.toggle(false), "reveal"); + completions[0].resolve(); + await Promise.resolve(); + assert.equal(surface.style.willChange, "auto"); + + assert.equal(controller.toggle(false), "conceal"); + assert.equal(calls.includes("cancel"), false); + assert.equal(surface.style.willChange, "opacity"); + assert.equal(calls.filter((call) => typeof call === "object").length, 1); +}); + +test("a stale completion cannot release the hint during a reversal", async () => { + const { completions, controller, surface } = fixture(); + + controller.toggle(false); + controller.toggle(false); + completions[0].resolve(); + await Promise.resolve(); + assert.equal(surface.style.willChange, "opacity"); + + completions[1].resolve(); + await Promise.resolve(); + assert.equal(surface.style.willChange, "auto"); +}); + test("reduced motion settles immediately without allocating an animation", () => { const { calls, controller, surface } = fixture(); assert.equal(controller.toggle(true), "reveal"); diff --git a/desktop/src/features/terminal/fadeController.ts b/desktop/src/features/terminal/fadeController.ts index 0950a6bc23..91cd5bd729 100644 --- a/desktop/src/features/terminal/fadeController.ts +++ b/desktop/src/features/terminal/fadeController.ts @@ -1,5 +1,5 @@ -export const REVEAL_DURATION_MS = 720; -export const CONCEAL_DURATION_MS = 420; +export const REVEAL_DURATION_MS = 360; +export const CONCEAL_DURATION_MS = 210; export const FADE_EASING = "cubic-bezier(.2,.8,.2,1)"; type Direction = "reveal" | "conceal"; @@ -78,11 +78,11 @@ export class FadeController { void animation.finished .then(() => { if (this.#animation === animation && this.#token === token) { - this.settle(next); + this.#surface.style.willChange = "auto"; } }) .catch(() => { - // Reversal/cancellation rejects `finished`; the newer token owns state. + // Reversal/cancellation rejects `finished`; the live timeline owns state. }); return next; }