Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/lib/__tests__/transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
fadeQuick,
modalScale,
overlayFade,
overlayFlyUp,
slideDown,
expandSlide,
motionSuppressed,
} from '../transitions';

// Regression lock for #269: in web mode (and whenever the user asks for
// reduced motion) every shared preset must resolve to a zero-duration
// transition. Svelte's out-transition completion never fires in background
// panes, so a real transition there stacked stale views forever.

const node = document.createElement('div');

afterEach(() => {
delete (window as any).__TAURI_INTERNALS__;
});

describe('motion suppression (web mode + reduced motion)', () => {
it('is always on in a plain browser', () => {
// vitest runs without __TAURI_INTERNALS__ → web mode → suppressed.
expect(motionSuppressed()).toBe(true);
});

it('is on for desktop when prefers-reduced-motion is set', () => {
(window as any).__TAURI_INTERNALS__ = {};
(window as any).matchMedia = (query: string) => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
});
expect(motionSuppressed()).toBe(true);
});

it('is off for desktop when the user allows motion', () => {
(window as any).__TAURI_INTERNALS__ = {};
(window as any).matchMedia = (query: string) => ({
matches: false,
media: query,
});
expect(motionSuppressed()).toBe(false);
});
});

describe('presets collapse to zero duration when suppressed', () => {
it('every shared preset returns duration 0 in web mode', () => {
const presets = [fadeQuick, modalScale, overlayFade, overlayFlyUp, slideDown, expandSlide];
for (const preset of presets) {
const config = preset(node);
expect(config.duration).toBe(0);
}
});
});
26 changes: 15 additions & 11 deletions src/lib/transitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,32 @@

import { fade, fly, scale, slide } from 'svelte/transition';
import type { TransitionConfig } from 'svelte/transition';
import { isWebMode } from './ts/transport';

type EasingFunc = (t: number) => number;

// Web-mode guard: in a background browser pane, Svelte's out-transition
// completion callbacks never fire — keyed swaps and overlay closes leave
// stale DOM stacked forever. A zero-duration no-op keeps the API contract
// (Svelte requires a function) while finishing instantly; desktop keeps
// the real presets untouched.
// Motion suppression guard, checked per-invocation:
// 1. Web mode (background panes): Svelte's out-transition completion never
// fires there, so real transitions stacked stale views forever (#269) —
// always suppress.
// 2. Desktop: animate, unless the user asked for reduced motion.
const noOp = (): TransitionConfig => ({ duration: 0 });
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** True when transitions should be skipped entirely. */
export function motionSuppressed(): boolean {
if (typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) {
return prefersReducedMotion();
}
return true; // plain browser (incl. background panes)
}

const guarded = <A extends unknown[]>(
preset: (node: Element, ...args: A) => TransitionConfig,
): ((node: Element, ...args: A) => TransitionConfig) => {
if (!isWebMode) {
// Checked per-invocation so a live OS setting change is respected.
return (node, ...args) => (prefersReducedMotion() ? { duration: 0 } : preset(node, ...args));
}
return (node, ..._args) => ({ duration: 0 });
return (node, ...args) => (motionSuppressed() ? { duration: 0 } : preset(node, ...args));
};

// Easing — cubic-bezier approximations for smooth UI motion
Expand Down