From 9316dc05629b1cf3a7fef128a9d28235f685c7ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 03:44:35 +0000 Subject: [PATCH 1/2] desktop: exit animations for dialogs, menus, popovers, toasts and the fullscreen player Closed surfaces used to unmount in the same commit that closed them, so the Material 3 entrance animations had no matching exit: dialogs, the palette, context and select menus, the search and jobs popovers, toasts, the narrow-layout drawer and the fullscreen player all cut to nothing. lib/presence.ts keeps a closed surface mounted for one exit animation: it swaps `fade-in` for `closing` (the new exit keyframes, on the emphasised-accelerate easing, held with `forwards`), makes the element inert so it takes no focus, keys or clicks on the way out, and unmounts on animationend, at once when nothing animates (reduced motion), or on a fallback timeout. Focus return, the Connect picker close and leaving window fullscreen stay keyed on the live state, so none of that waits. The docked side panel still closes instantly, being part of the layout. The three hardcoded transition durations left in global.css now use the motion tokens. exit-motion.spec covers the closing state and the reduced-motion shortcut; presence.test covers the toast list merge. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MdqKjaWu5KQu7R1syAi9mp --- desktop/README.md | 4 +- desktop/e2e/exit-motion.spec.ts | 103 ++++++++++++ desktop/src/renderer/App.tsx | 3 +- .../renderer/components/CommandPalette.tsx | 9 +- .../src/renderer/components/ContextMenu.tsx | 26 +-- desktop/src/renderer/components/Dialogs.tsx | 18 ++- .../src/renderer/components/RightPanel.tsx | 9 +- desktop/src/renderer/components/Toasts.tsx | 7 +- desktop/src/renderer/components/TopBar.tsx | 13 +- desktop/src/renderer/components/controls.tsx | 7 +- desktop/src/renderer/lib/presence.test.ts | 45 ++++++ desktop/src/renderer/lib/presence.ts | 153 ++++++++++++++++++ desktop/src/renderer/styles/controls.css | 2 + desktop/src/renderer/styles/global.css | 32 +++- desktop/src/renderer/styles/m3-shell.css | 9 ++ .../src/renderer/views/FullscreenPlayer.tsx | 21 ++- 16 files changed, 420 insertions(+), 41 deletions(-) create mode 100644 desktop/e2e/exit-motion.spec.ts create mode 100644 desktop/src/renderer/lib/presence.test.ts create mode 100644 desktop/src/renderer/lib/presence.ts diff --git a/desktop/README.md b/desktop/README.md index 08b9b79..3d69e92 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -24,7 +24,9 @@ pnpm test:e2e # Playwright Electron e2e (needs a display: `xvfb-run -a pnp # headless Linux box; run `pnpm build` first). e2e/*.spec.ts except native.spec.ts # run against the FakeCore (a11y.spec: axe over every view in both themes; # keyboard-a11y.spec: keyboard-only walkthrough; motion-contrast.spec: reduced - # motion, measured contrast, forced colours, 200% zoom); + # motion, measured contrast, forced colours, 200% zoom; exit-motion.spec: + # closed dialogs, menus, popovers, toasts and the fullscreen player animate + # out, inert, before they unmount, and go at once under reduced motion); # e2e/native.spec.ts runs the REAL core (native addon, # skipped when native/ isn't built) against e2e/fake-navidrome.ts, a small # Subsonic JSON server: setup → sync → play → queue → rate → undo → lyrics → diff --git a/desktop/e2e/exit-motion.spec.ts b/desktop/e2e/exit-motion.spec.ts new file mode 100644 index 0000000..257ecbd --- /dev/null +++ b/desktop/e2e/exit-motion.spec.ts @@ -0,0 +1,103 @@ +// Closed surfaces animate out: a dialog, menu, popover, toast or the fullscreen +// player stays mounted for one exit animation, inert and untouchable, then +// unmounts (lib/presence.ts). Under reduced motion it goes at once. The exits +// are slowed here so the closing state is observable. +import type { Locator, Page } from "@playwright/test"; +import { completeSetup, expect, playFirstAlbum, test } from "./fixtures"; + +/** Stretch every exit so the test can look at it mid-flight. */ +async function slowExits(page: Page): Promise { + await page.evaluate(() => { + document.documentElement.style.setProperty("--dur-exit", "1500ms"); + document.documentElement.style.setProperty("--dur-short", "1500ms"); + }); +} + +/** The element's closing state: the class, inertness, and an animation of its own running. */ +async function exitState(el: Locator): Promise<{ closing: boolean; inert: boolean; animating: boolean }> { + return el.evaluate((n) => ({ + closing: n.classList.contains("closing"), + inert: n.hasAttribute("inert"), + animating: n.getAnimations().some((a) => a.playState === "running"), + })); +} + +async function expectExiting(el: Locator): Promise { + await expect.poll(() => exitState(el)).toEqual({ closing: true, inert: true, animating: true }); + await expect(el).toHaveCount(0); +} + +test.describe("exit animations", () => { + test("menus, dialogs, the palette, popovers, toasts and the fullscreen player leave through an exit animation", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + await slowExits(page); + + // Context menu: Escape starts the exit; it is inert at once and then gone. + await page.getByTestId("track-row").first().click({ button: "right" }); + const menu = page.getByTestId("context-menu"); + await expect(menu).toBeVisible(); + await page.keyboard.press("Escape"); + await expectExiting(menu); + + // Dialog: the sheet and its scrim leave together; the shell is live again from the first frame. + await page.getByTestId("track-row").first().click({ button: "right" }); + await menu.locator('[data-action="addToPlaylist"]').click(); + const dialog = page.getByTestId("dialog-addToPlaylist"); + await expect(dialog).toBeVisible(); + expect(await page.getByTestId("app-body").getAttribute("inert")).not.toBeNull(); + await page.keyboard.press("Escape"); + await expect.poll(() => exitState(dialog)).toEqual({ closing: true, inert: true, animating: true }); + expect(await page.locator(".overlay").evaluate((n) => n.classList.contains("closing") && n.hasAttribute("inert"))).toBe(true); + expect(await page.getByTestId("app-body").getAttribute("inert")).toBeNull(); + await expect(dialog).toHaveCount(0); + + // Command palette. + await page.getByTestId("content").click(); + await page.keyboard.press("Control+k"); + const palette = page.getByTestId("palette"); + await expect(palette).toBeVisible(); + await page.keyboard.press("Escape"); + await expectExiting(palette); + + // Jobs popover. + await page.getByTestId("jobs-button").click(); + const popover = page.getByTestId("jobs-popover"); + await expect(popover).toBeVisible(); + await page.getByTestId("jobs-button").click(); + await expectExiting(popover); + + // A toast: dismissed by its action, it sinks away rather than vanishing. + const toast = page.getByTestId("toast").filter({ hasText: "Play" }).first(); + await expect(toast).toBeVisible(); + await toast.getByTestId("toast-action").click(); + await expect.poll(() => exitState(toast)).toEqual({ closing: true, inert: true, animating: true }); + await expect(page.locator('[data-testid="toast"].closing')).toHaveCount(0); + + // The fullscreen player: fades out, focus already back in the shell. + await page.getByTestId("content").click(); + await page.keyboard.press("f"); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toBeVisible(); + await page.keyboard.press("Escape"); + await expect.poll(() => exitState(fs)).toEqual({ closing: true, inert: true, animating: true }); + expect(await page.evaluate(() => !document.activeElement?.closest('[data-testid="fullscreen-player"]'))).toBe(true); + await expect(fs).toHaveCount(0); + }); + + test("under reduced motion a closed surface goes at once", async ({ hocket }) => { + const { page } = hocket; + await page.emulateMedia({ reducedMotion: "reduce" }); + await completeSetup(page); + await slowExits(page); + await page.getByTestId("content").click(); + await page.keyboard.press("Control+k"); + const palette = page.getByTestId("palette"); + await expect(palette).toBeVisible(); + await page.keyboard.press("Escape"); + // Well inside the stretched exit: nothing waited for an animation that never ran. + await expect(palette).toHaveCount(0, { timeout: 500 }); + expect(await page.evaluate(() => document.getAnimations().filter((a) => a.playState === "running").length)).toBe(0); + }); +}); diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index a792735..ada692e 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -32,7 +32,6 @@ export function App() { const ready = useApp((s) => s.ready); // The core probes before installing: ServersChanged only arrives once a server is real. const hasServer = useApp((s) => s.servers.length > 0); - const fullscreen = useApp((s) => s.fullscreen); const coreKind = useApp((s) => s.meta?.coreKind); const network = useApp((s) => s.network); const panels = useApp((s) => s.panels); @@ -68,7 +67,7 @@ export function App() { {/* Covers the whole window (sidebar, panels and player bar included). */} - {fullscreen ? : null} + diff --git a/desktop/src/renderer/components/CommandPalette.tsx b/desktop/src/renderer/components/CommandPalette.tsx index 547e8a6..85d096b 100644 --- a/desktop/src/renderer/components/CommandPalette.tsx +++ b/desktop/src/renderer/components/CommandPalette.tsx @@ -10,6 +10,7 @@ import { formatChord, parseChord } from "../store/shortcuts"; import { Artwork } from "./Artwork"; import { Icon, hasIcon } from "./Icon"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; interface Item extends Rankable { icon?: string; @@ -31,6 +32,8 @@ export function CommandPalette() { const [active, setActive] = useState(0); const input = useRef(null); const seq = useRef(0); + // The closed palette stays mounted, inert, while its exit plays. + const { value: shown, closing, motion, exitProps } = usePresence(open || undefined); useReturnFocus(open); useEffect(() => { @@ -76,7 +79,7 @@ export function CommandPalette() { if (open) document.getElementById(`pal-opt-${active}`)?.scrollIntoView({ block: "nearest" }); }, [active, open]); - if (!open) return null; + if (!shown) return null; const run = (i: Item) => { setOpen(false); i.run(); @@ -91,8 +94,8 @@ export function CommandPalette() { }; const firstAction = ranked.findIndex((r) => r.item.kind === "action"); return ( -
e.target === e.currentTarget && setOpen(false)}> -
+
e.target === e.currentTarget && setOpen(false)}> +
setQuery(e.target.value)} role="combobox" aria-label={t("action.palette")} aria-autocomplete="list" aria-expanded={ranked.length > 0} aria-controls="palette-list" aria-activedescendant={ranked[active] ? `pal-opt-${active}` : undefined} data-testid="palette-input" /> {ranked.length === 0 ?
{t("palette.empty")}
: null}
diff --git a/desktop/src/renderer/components/ContextMenu.tsx b/desktop/src/renderer/components/ContextMenu.tsx index c2668a3..3b244c6 100644 --- a/desktop/src/renderer/components/ContextMenu.tsx +++ b/desktop/src/renderer/components/ContextMenu.tsx @@ -12,6 +12,7 @@ import { useApp } from "../store/app"; import { executeAction, type ActionContext } from "../store/actions"; import { Icon, hasIcon } from "./Icon"; import { handOnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; /** Where focus goes back to when the menu closes. */ let returnFocus: HTMLElement | null = null; @@ -54,31 +55,34 @@ export function openContextMenuFromKeyboard(): boolean { } export function ContextMenu() { - const menu = useApp((s) => s.contextMenu); + const live = useApp((s) => s.contextMenu); const close = useApp((s) => s.closeContextMenu); const shortcuts = useApp((s) => s.shortcuts); const platform = useApp((s) => s.meta?.platform ?? "linux"); const ref = useRef(null); + // The closed menu stays mounted, inert, while its exit plays. Everything + // below that positions, focuses or listens is keyed on the live state. + const { value: menu, motion, exitProps } = usePresence(live, ref); const [pos, setPos] = useState({ x: 0, y: 0 }); const [active, setActive] = useState(-1); const actions = menu?.actions ?? []; useLayoutEffect(() => { - if (!menu || !ref.current) return; + if (!live || !ref.current) return; // Layout size, not getBoundingClientRect(): the menu opens scaled down by its entrance animation. const r = { width: ref.current.offsetWidth, height: ref.current.offsetHeight }; - const x = Math.min(menu.x, window.innerWidth - r.width - 6); - const y = Math.min(menu.y, window.innerHeight - r.height - 6); + const x = Math.min(live.x, window.innerWidth - r.width - 6); + const y = Math.min(live.y, window.innerHeight - r.height - 6); setPos({ x: Math.max(4, x), y: Math.max(4, y) }); // Keyboard-opened menus start on the first enabled item, like native menus. - setActive(menu.keyboard ? menu.actions.findIndex((a) => a.enabled) : -1); + setActive(live.keyboard ? live.actions.findIndex((a) => a.enabled) : -1); ref.current.focus(); - }, [menu]); + }, [live]); // Focus goes back to the trigger when the menu closes (unless an action moved it on purpose). const wasOpen = useRef(false); useEffect(() => { - if (menu) { wasOpen.current = true; return; } + if (live) { wasOpen.current = true; return; } if (!wasOpen.current) return; wasOpen.current = false; const el = returnFocus; @@ -89,10 +93,10 @@ export function ContextMenu() { if (useApp.getState().dialog || useApp.getState().paletteOpen) return; handOnFocus(null); if (el && el.isConnected && focusLost) el.focus({ preventScroll: true }); - }, [menu]); + }, [live]); useEffect(() => { - if (!menu) return; + if (!live) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) close(); }; @@ -105,7 +109,7 @@ export function ContextMenu() { window.removeEventListener("blur", onBlur); window.removeEventListener("resize", onBlur); }; - }, [menu, close]); + }, [live, close]); if (!menu) return null; const run = (a: ActionDescriptor) => { @@ -152,7 +156,7 @@ export function ContextMenu() { }; let lastCategory: string | undefined; return ( -
= 0 ? `cm-item-${active}` : undefined} tabIndex={-1} style={{ left: pos.x, top: pos.y }} onKeyDown={onKey} data-testid="context-menu"> +
= 0 ? `cm-item-${active}` : undefined} tabIndex={-1} style={{ left: pos.x, top: pos.y }} onKeyDown={onKey} data-testid="context-menu"> {actions.map((a, i) => { const sep = lastCategory !== undefined && lastCategory !== a.category; lastCategory = a.category; diff --git a/desktop/src/renderer/components/Dialogs.tsx b/desktop/src/renderer/components/Dialogs.tsx index 282daa5..0a3f78f 100644 --- a/desktop/src/renderer/components/Dialogs.tsx +++ b/desktop/src/renderer/components/Dialogs.tsx @@ -9,21 +9,25 @@ import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { Artwork } from "./Artwork"; import { Icon } from "./Icon"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePresence } from "../lib/presence"; export function Dialogs() { - const dialog = useApp((s) => s.dialog); + const live = useApp((s) => s.dialog); const close = useApp((s) => s.closeDialog); const ref = useRef(null); - useReturnFocus(!!dialog); + // The closed dialog stays mounted, inert, while its exit plays; focus goes + // back and the Connect picker closes on the live state, not the animation. + const { value: dialog, closing, motion, exitProps } = usePresence(live, ref); + useReturnFocus(!!live); useEffect(() => { - if (!dialog) return; + if (!live) return; // Initial focus: the field, else the safe (non-destructive) button, else the first control. const root = ref.current; const first = root?.querySelector("input, textarea, select") ?? root?.querySelector("[data-autofocus]") ?? root?.querySelector("button, [tabindex='0']"); first?.focus(); - if (dialog.kind === "connect") return () => bridge().dispatch({ type: "closeHandoffPicker" }); + if (live.kind === "connect") return () => bridge().dispatch({ type: "closeHandoffPicker" }); return undefined; - }, [dialog]); + }, [live]); if (!dialog) return null; const onKey = (e: React.KeyboardEvent) => { e.stopPropagation(); @@ -35,8 +39,8 @@ export function Dialogs() { trapTab(e); }; return ( -
e.target === e.currentTarget && close()}> -
+
e.target === e.currentTarget && close()}> +
{dialog.kind === "prompt" ? : null} {dialog.kind === "confirm" ? : null} {dialog.kind === "addToPlaylist" ? : null} diff --git a/desktop/src/renderer/components/RightPanel.tsx b/desktop/src/renderer/components/RightPanel.tsx index b7862e5..fcf042f 100644 --- a/desktop/src/renderer/components/RightPanel.tsx +++ b/desktop/src/renderer/components/RightPanel.tsx @@ -12,6 +12,7 @@ import { Tabs, tabPanelProps } from "./Tabs"; import { bridge } from "../core/bridge"; import { SK } from "@shared/settings-keys"; import { NARROW, useMediaQuery } from "../lib/media"; +import { usePresence } from "../lib/presence"; export function RightPanel() { const panels = useApp((s) => s.panels); @@ -46,13 +47,17 @@ export function RightPanel() { const narrow = useMediaQuery(NARROW); const drawerOpen = useApp((s) => s.drawerOpen); const setDrawerOpen = useApp((s) => s.setDrawerOpen); - if (narrow ? !drawerOpen : !panels.rightOpen) return null; + // The drawer withdraws before it unmounts; the docked panel is part of the + // layout (the content pane widens the moment it closes), so it just goes. + const open = narrow ? drawerOpen : panels.rightOpen; + const presence = usePresence(open || undefined, ref, { instant: !narrow }); + if (!presence.value) return null; const qc = panels.queueCollapsed; const lc = panels.lyricsCollapsed; const queueFlex = qc ? "0 0 32px" : lc ? "1 1 auto" : `${panels.splitRatio} 1 0`; const lyricsFlex = lc ? "0 0 32px" : qc ? "1 1 auto" : `${1 - panels.splitRatio} 1 0`; return ( -