diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 9ed34b2fea2..9a661e5fadd 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -38,6 +38,14 @@ export default defineConfig({ use: { screenshot: 'on', trace: { mode: 'on', screenshots: true, snapshots: true, sources: true }, + // Emulate prefers-reduced-motion: reduce so all CSS transitions and + // animations resolve instantly. This prevents boot/connecting overlays + // from being mid-fade when a screenshot fires, and skips JS-driven exit + // choreography in components that check matchMedia (onboarding, connecting + // overlay, DecodeText). Without this, screenshots capture the loading bar + // or overlay at a transient opacity because the text-content check fires + // before the visual transition finishes. + reducedMotion: 'reduce', }, expect: { toHaveScreenshot: { diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 79bc9935804..d2050f11fed 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -35,11 +35,20 @@ function forcedPreview(): boolean { } } +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) +} + export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) + const reduce = prefersReducedMotion() + // Under reduced motion, skip the multi-phase exit choreography (text-out → + // hold → overlay fade) and jump straight to gone so the overlay unmounts + // the instant the gateway opens. E2E screenshots rely on this to avoid + // catching the overlay mid-fade. const [phase, setPhase] = useState('live') // Once cold boot has completed once, never resurrect the fullscreen overlay // — soft gateway switches keep the shell and reskeleton the sidebar instead. @@ -74,16 +83,32 @@ export function GatewayConnectingOverlay() { return } - if (previewing) { + if(reduce) { + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + setPhase('gone') + } + + if (previewing) { + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + if(reduce) { + setPhase('gone') + + return + } + const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS) return () => window.clearTimeout(id) } if (gatewayState === 'open' && shownRef.current) { - setPhase('text-out') + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + setPhase(reduce ? 'gone' : 'text-out') } - }, [phase, previewing, gatewayState]) + }, [phase, previewing, gatewayState, reduce]) // Advance the exit choreography: text-out -> overlay-out -> gone. useEffect(() => { diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx index e5bc470ab07..243de60f620 100644 --- a/apps/desktop/src/components/ui/decode-text.tsx +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -23,6 +23,10 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*' const TICK_MS = 45 const HOLD_TICKS = 16 +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) +} + function scrambled(tail: string, resolvedCount: number): string { return Array.from(tail, (ch, i) => ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0] @@ -62,6 +66,15 @@ export function DecodeText({ return } + // Under reduced motion, skip the scramble interval and render the fully + // resolved text immediately. The cursor blink (CSS animation) is also + // killed by the blanket reduced-motion CSS rule. + if (prefersReducedMotion()) { + setTail(tailText) + + return + } + let resolved = 0 let hold = 0 diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d1e45ae6ed4..86a87dbf0ea 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -5,6 +5,22 @@ @import '@vscode/codicons/dist/codicon.css'; @custom-variant dark (&:is(.dark *)); +/* Blanket reduced-motion override: kill ALL CSS animations and transitions + when the user (or the E2E test harness via prefers-reduced-motion) requests + it. Per-component @media rules below handle specific cases; this catches + everything else so overlays and loading bars resolve instantly instead of + being caught mid-fade by a screenshot. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* Sidebar sections: tall viewports give each its own scroller; compact ones (this variant) flatten everything into one shared scroll. See ChatSidebar. */ @custom-variant compact (@media (max-height: 768px));