From ad77bc7873caab84a4bf68e0c8fb60698345ace7 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 17 Jul 2026 23:50:47 -0400 Subject: [PATCH 1/3] fix(accessibility): honor reduced motion --- src/components/AskCodeCard.tsx | 2 ++ src/components/HelpDialog.tsx | 1 + src/lib/reduced-motion-styles.test.ts | 41 +++++++++++++++++++++++++++ src/styles.css | 10 +++++++ 4 files changed, 54 insertions(+) create mode 100644 src/lib/reduced-motion-styles.test.ts diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index 94ffee85..4a952e1b 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -149,6 +149,7 @@ export function AskCodeCard(props: AskCodeCardProps) { > {response()} { + it('disables nonessential task and pulse animations', () => { + const block = reducedMotionBlock(); + const animatedSelectors = [ + '.task-appearing', + '.task-item-appearing', + '.task-removing', + '.task-item-removing', + '.status-dot-pulse', + '.askcode-loading-pulse', + '.keybinding-recording-pulse', + ]; + + for (const selector of animatedSelectors) { + expect(block).toContain(selector); + } + expect(block).toMatch(/animation:\s*none\s*!important/); + }); +}); diff --git a/src/styles.css b/src/styles.css index 227949a2..e5b91926 100644 --- a/src/styles.css +++ b/src/styles.css @@ -986,6 +986,16 @@ textarea::placeholder { .projects-collapser { transition: none; } + + .task-appearing, + .task-item-appearing, + .task-removing, + .task-item-removing, + .status-dot-pulse, + .askcode-loading-pulse, + .keybinding-recording-pulse { + animation: none !important; + } } .tiling-layout-shell { From 18a214dc81113bbec33901aec41a8c871410916e Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Sat, 18 Jul 2026 09:37:38 -0400 Subject: [PATCH 2/3] fix(accessibility): preserve reduced-motion status cues --- src/components/HelpDialog.tsx | 2 +- src/components/TilingLayout.tsx | 12 ++++- src/lib/reduced-motion-styles.test.ts | 66 +++++++++++++++++++-------- src/styles.css | 43 ++++++++++------- 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index cf8f09af..e6e9b36e 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -364,7 +364,7 @@ export function HelpDialog(props: HelpDialogProps) { { const task = store.tasks[panelId]; const terminal = store.terminals[panelId]; + const appearanceClass = shouldAnimateTaskAppearance() ? 'task-appearing' : undefined; // eslint-disable-next-line solid/components-return-once if (!task && !terminal) return
; return ( @@ -252,7 +258,7 @@ export function TilingLayout() { class={ task?.closingStatus === 'removing' || terminal?.closingStatus === 'removing' ? 'task-removing' - : 'task-appearing' + : appearanceClass } style={{ height: '100%', @@ -267,6 +273,10 @@ export function TilingLayout() { if (e.animationName === 'taskAppear') e.currentTarget.classList.remove('task-appearing'); }} + onAnimationCancel={(e) => { + if (e.animationName === 'taskAppear') + e.currentTarget.classList.remove('task-appearing'); + }} > ( diff --git a/src/lib/reduced-motion-styles.test.ts b/src/lib/reduced-motion-styles.test.ts index f7016690..2bc350f9 100644 --- a/src/lib/reduced-motion-styles.test.ts +++ b/src/lib/reduced-motion-styles.test.ts @@ -3,39 +3,69 @@ import { resolve } from 'path'; import { describe, expect, it } from 'vitest'; const css = readFileSync(resolve(__dirname, '../styles.css'), 'utf8'); +const helpDialog = readFileSync(resolve(__dirname, '../components/HelpDialog.tsx'), 'utf8'); +const tilingLayout = readFileSync(resolve(__dirname, '../components/TilingLayout.tsx'), 'utf8'); -function reducedMotionBlock(): string { +function reducedMotionBlock(): { block: string; end: number } { const marker = '@media (prefers-reduced-motion: reduce)'; const start = css.indexOf(marker); expect(start).toBeGreaterThanOrEqual(0); + expect(css.lastIndexOf(marker)).toBe(start); const openingBrace = css.indexOf('{', start); let depth = 0; for (let index = openingBrace; index < css.length; index += 1) { if (css[index] === '{') depth += 1; if (css[index] === '}') depth -= 1; - if (depth === 0) return css.slice(start, index + 1); + if (depth === 0) return { block: css.slice(start, index + 1), end: index }; } throw new Error('Unclosed reduced-motion media query'); } +function expectRule(block: string, selectors: string[], declaration: RegExp): void { + const selectorPattern = selectors + .map((selector) => selector.replaceAll('.', '\\.')) + .join('\\s*,\\s*'); + const rule = new RegExp( + `${selectorPattern}\\s*\\{[^}]*${declaration.source}[^}]*\\}`, + declaration.flags, + ); + expect(block).toMatch(rule); +} + describe('reduced-motion styles', () => { - it('disables nonessential task and pulse animations', () => { - const block = reducedMotionBlock(); - const animatedSelectors = [ - '.task-appearing', - '.task-item-appearing', - '.task-removing', - '.task-item-removing', - '.status-dot-pulse', - '.askcode-loading-pulse', - '.keybinding-recording-pulse', - ]; - - for (const selector of animatedSelectors) { - expect(block).toContain(selector); - } - expect(block).toMatch(/animation:\s*none\s*!important/); + it('pairs each main-app selector group with its reduced-motion override', () => { + const { block, end } = reducedMotionBlock(); + + expectRule(block, ['.projects-collapser'], /transition:\s*none\s*;/); + expectRule( + block, + [ + '.task-appearing', + '.task-item-appearing', + '.task-removing', + '.task-item-removing', + '.status-dot-pulse', + ], + /animation:\s*none\s*;/, + ); + expectRule(block, ['.status-dot-pulse'], /outline:\s*1px solid var\(--fg-muted\)\s*;/); + expectRule( + block, + ['.askcode-loading-pulse', '.keybinding-key'], + /animation:\s*none\s*!important\s*;/, + ); + expect(block).not.toContain('.inline-spinner'); + expect(block).not.toContain('keybinding-recording-pulse'); + expect(css.slice(end + 1).trim()).toBe(''); + }); + + it('avoids stale entry and keybinding animation classes', () => { + expect(tilingLayout).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches"); + expect(tilingLayout).toContain('const appearanceClass = shouldAnimateTaskAppearance()'); + expect(tilingLayout).toContain('onAnimationCancel'); + expect(helpDialog).toContain('class="keybinding-key"'); + expect(helpDialog).not.toContain('keybinding-recording-pulse'); }); }); diff --git a/src/styles.css b/src/styles.css index e5b91926..8fff4452 100644 --- a/src/styles.css +++ b/src/styles.css @@ -982,22 +982,6 @@ textarea::placeholder { overflow: hidden; } -@media (prefers-reduced-motion: reduce) { - .projects-collapser { - transition: none; - } - - .task-appearing, - .task-item-appearing, - .task-removing, - .task-item-removing, - .status-dot-pulse, - .askcode-loading-pulse, - .keybinding-recording-pulse { - animation: none !important; - } -} - .tiling-layout-shell { position: relative; flex: 1; @@ -2209,3 +2193,30 @@ body.dragging-task * { opacity: 0.3; } } + +/* Keep reduced-motion overrides last so class-based rules win by source order. */ +@media (prefers-reduced-motion: reduce) { + .projects-collapser { + transition: none; + } + + .task-appearing, + .task-item-appearing, + .task-removing, + .task-item-removing, + .status-dot-pulse { + animation: none; + } + + /* Preserve a static busy-state distinction after removing the pulse. */ + .status-dot-pulse { + outline: 1px solid var(--fg-muted); + outline-offset: 1px; + } + + /* These animations are declared inline and require an important override. */ + .askcode-loading-pulse, + .keybinding-key { + animation: none !important; + } +} From ca484c6b813de308851a195d528d27de5ab69d1f Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Tue, 21 Jul 2026 09:07:04 -0400 Subject: [PATCH 3/3] fix(accessibility): address reduced motion follow-up --- src/components/Sidebar.tsx | 11 +++++++++- src/components/StatusDot.tsx | 2 +- src/components/TilingLayout.tsx | 6 +----- src/lib/reduced-motion-styles.test.ts | 20 +++++++----------- src/lib/reducedMotion.test.ts | 29 +++++++++++++++++++++++++++ src/lib/reducedMotion.ts | 4 ++++ src/styles.css | 5 +++-- 7 files changed, 55 insertions(+), 22 deletions(-) create mode 100644 src/lib/reducedMotion.test.ts create mode 100644 src/lib/reducedMotion.ts diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 485c9443..e98368ab 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -48,6 +48,7 @@ import { abbreviateHomePath } from '../lib/path'; import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import type { ImportableWorktree } from '../ipc/types'; +import { shouldAnimateTaskAppearance } from '../lib/reducedMotion'; const DRAG_THRESHOLD = 5; const SIDEBAR_DEFAULT_WIDTH = 240; @@ -171,9 +172,17 @@ export function TaskRowShell(props: { style?: JSX.CSSProperties; children: JSX.Element; }) { + const className = () => + shouldAnimateTaskAppearance() + ? props.class + : props.class + .split(/\s+/) + .filter((className) => className !== 'task-item-appearing') + .join(' '); + return (
props.attention === 'active' || props.status === 'busy'; return ( { +describe('main stylesheet reduced-motion styles', () => { it('pairs each main-app selector group with its reduced-motion override', () => { const { block, end } = reducedMotionBlock(); @@ -50,7 +48,8 @@ describe('reduced-motion styles', () => { ], /animation:\s*none\s*;/, ); - expectRule(block, ['.status-dot-pulse'], /outline:\s*1px solid var\(--fg-muted\)\s*;/); + expectRule(block, ['.status-dot-ring'], /outline:\s*1px solid var\(--fg-muted\)\s*;/); + expectRule(block, ['.status-dot-ring'], /outline-offset:\s*2px\s*;/); expectRule( block, ['.askcode-loading-pulse', '.keybinding-key'], @@ -58,14 +57,9 @@ describe('reduced-motion styles', () => { ); expect(block).not.toContain('.inline-spinner'); expect(block).not.toContain('keybinding-recording-pulse'); - expect(css.slice(end + 1).trim()).toBe(''); - }); - - it('avoids stale entry and keybinding animation classes', () => { - expect(tilingLayout).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches"); - expect(tilingLayout).toContain('const appearanceClass = shouldAnimateTaskAppearance()'); - expect(tilingLayout).toContain('onAnimationCancel'); - expect(helpDialog).toContain('class="keybinding-key"'); - expect(helpDialog).not.toContain('keybinding-recording-pulse'); + expect( + css.slice(end + 1).trim(), + 'Keep the reduced-motion block at the end of styles.css so its class rules win by source order', + ).toBe(''); }); }); diff --git a/src/lib/reducedMotion.test.ts b/src/lib/reducedMotion.test.ts new file mode 100644 index 00000000..7633d2c0 --- /dev/null +++ b/src/lib/reducedMotion.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { shouldAnimateTaskAppearance } from './reducedMotion'; + +describe('shouldAnimateTaskAppearance', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('allows animation when reduced motion is not requested', () => { + const matchMedia = vi.fn().mockReturnValue({ matches: false }); + vi.stubGlobal('window', { matchMedia }); + + expect(shouldAnimateTaskAppearance()).toBe(true); + expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + }); + + it('disables animation when reduced motion is requested', () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal('window', { matchMedia }); + + expect(shouldAnimateTaskAppearance()).toBe(false); + }); + + it('allows animation when matchMedia is unavailable', () => { + vi.stubGlobal('window', {}); + + expect(shouldAnimateTaskAppearance()).toBe(true); + }); +}); diff --git a/src/lib/reducedMotion.ts b/src/lib/reducedMotion.ts new file mode 100644 index 00000000..5cb05bc6 --- /dev/null +++ b/src/lib/reducedMotion.ts @@ -0,0 +1,4 @@ +export function shouldAnimateTaskAppearance(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true; + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/src/styles.css b/src/styles.css index 8fff4452..1cfbf3bc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1441,6 +1441,7 @@ textarea::placeholder { } .inline-spinner { + /* Rotation is the loading cue, so functional spinners remain animated under reduced motion. */ display: inline-block; box-sizing: border-box; width: 12px; @@ -2209,9 +2210,9 @@ body.dragging-task * { } /* Preserve a static busy-state distinction after removing the pulse. */ - .status-dot-pulse { + .status-dot-ring { outline: 1px solid var(--fg-muted); - outline-offset: 1px; + outline-offset: 2px; } /* These animations are declared inline and require an important override. */