fix(desktop): kill boot overlay fade-race in e2e screenshots

Screenshots were catching the CONNECTING overlay and onboarding Preparing
loading bar mid-transition because the wait helpers fire on text content
while visual state lags behind. Fix at the source via reduced motion:

- playwright.config.ts: emulate prefers-reduced-motion: reduce
- styles.css: blanket reduced-motion rule kills all CSS animations/transitions
- gateway-connecting-overlay.tsx: skip JS setTimeout exit choreography
  (text-out 360ms + hold 300ms + overlay fade 520ms) — jump straight to gone
- decode-text.tsx: skip scramble interval, render resolved text immediately
This commit is contained in:
ethernet 2026-07-17 15:24:56 -04:00
parent 2eb320a7bf
commit 0fd12ca11b
4 changed files with 65 additions and 3 deletions

View file

@ -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: {

View file

@ -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<Phase>('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(() => {

View file

@ -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

View file

@ -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));