diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 794e5c4c640..52d0684af93 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -264,7 +264,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype. A - **Layout density:** avoid jamming controls together. Preserve breathing room around the primary workflow, reduce competing buttons, and keep toolbar groups visually distinct. Dense screens are acceptable only when grouping, alignment, and hierarchy make scanning faster. - **Cards and containers:** cards must be visually distinct from their parent surface through the existing `card`/`border` treatment. Avoid nesting cards inside cards. If a section is not a repeated item, modal, or framed tool, consider an unframed layout or full-width band instead. - **Side-by-side layouts:** default to row-by-row layouts for complex workflows because they are easier to align and scan. Use side-by-side layouts only when space is constrained or comparison is the point, then polish column widths, baselines, and wrapping states carefully. -- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. +- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. Scripted scrolls ignore CSS, so pass them through `motionSafeScrollBehavior()`. - **SSH and latency:** assume actions may run remotely. Disable submit controls immediately, delay visible loading feedback when appropriate, and keep focus stable while remote data arrives. ### 1. Match in-flight feedback to perceived duration diff --git a/src/renderer/src/components/editor/markdown-anchor-scroll.ts b/src/renderer/src/components/editor/markdown-anchor-scroll.ts index daf31bc2bfe..cc780b12da4 100644 --- a/src/renderer/src/components/editor/markdown-anchor-scroll.ts +++ b/src/renderer/src/components/editor/markdown-anchor-scroll.ts @@ -1,4 +1,5 @@ import { MarkdownHeadingSlugger } from './markdown-heading-slug' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' // Why: duplicate headings need the same stateful suffixes as the preview // renderer (foo, foo-1, foo-2) or anchor links can jump to the wrong heading. @@ -16,7 +17,7 @@ export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string) const slugger = new MarkdownHeadingSlugger() for (const heading of headings) { if (slugger.slug(heading.textContent ?? '') === decoded) { - heading.scrollIntoView({ behavior: 'smooth', block: 'start' }) + heading.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'start' }) return } } diff --git a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts index b2ba13f41be..8e4797049fe 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts @@ -6,6 +6,7 @@ import type { MarkdownPreviewBlockRange } from './markdown-preview-types' import { requestMarkdownPreviewEditorRevealFrame } from './markdown-preview-editor-reveal' import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' function isMarkdownAnnotationNavigationClick(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -190,7 +191,7 @@ export function useMarkdownPreviewReviewActions({ return } findRenderedMarkdownReviewNoteCard(comment.id)?.scrollIntoView({ - behavior: 'smooth', + behavior: motionSafeScrollBehavior(), block: 'center', inline: 'nearest' }) @@ -226,7 +227,7 @@ export function useMarkdownPreviewReviewActions({ break } } - target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target?.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'center' }) }, [rootRef, scrollToSourceLine, setActiveReviewCommentId] ) diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts index 45603510138..4922e4e8011 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts @@ -13,6 +13,7 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning' import type { DiffComment } from '../../../../shared/diff-comment-types' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' type UseRichMarkdownReviewRailControllerOptions = { canAnnotateRichMarkdown: boolean @@ -182,7 +183,7 @@ export function useRichMarkdownReviewRailController({ setActiveReviewCommentId(comment.id) container.scrollTo({ top: Math.max(0, (sourceTop + sourceBottom) / 2 - container.clientHeight / 2), - behavior: 'smooth' + behavior: motionSafeScrollBehavior() }) pulseRichMarkdownSourceRange({ from: bounds.from, to: bounds.to }) }, @@ -257,5 +258,5 @@ function centerReviewNoteCard( return } const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2) - container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) + container.scrollTo({ top: Math.max(0, targetTop), behavior: motionSafeScrollBehavior() }) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx index 55d5a0cc71c..38c9df9730c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx @@ -31,6 +31,7 @@ import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { translate } from '@/i18n/i18n' import { PRCommentGroupView } from './comment-group' import { useNow } from '@/hooks/use-now' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' export type PRCommentsListDisplayMode = 'triage' | 'timeline' export const PR_COMMENT_LIST_DISPLAY_MODES: PRCommentsListDisplayMode[] = ['triage', 'timeline'] @@ -95,7 +96,7 @@ export function useCommentsListState({ function scrollElementBottomIntoView(element: HTMLElement): void { const scrollParent = findVerticalScrollParent(element) if (!scrollParent) { - element.scrollIntoView({ block: 'end', behavior: 'smooth' }) + element.scrollIntoView({ block: 'end', behavior: motionSafeScrollBehavior() }) return } @@ -106,7 +107,7 @@ export function useCommentsListState({ if (bottomOverflow > 0) { scrollParent.scrollTo({ top: scrollParent.scrollTop + bottomOverflow, - behavior: 'smooth' + behavior: motionSafeScrollBehavior() }) return } @@ -115,7 +116,7 @@ export function useCommentsListState({ if (topOverflow < 0) { scrollParent.scrollTo({ top: Math.max(0, scrollParent.scrollTop + topOverflow), - behavior: 'smooth' + behavior: motionSafeScrollBehavior() }) } } diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 41b330b2c33..283cfb081b7 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -33,6 +33,7 @@ import { import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers' import { translate } from '@/i18n/i18n' import { resolveAvailableBrowserSessionHostId } from './browser-session-host-selection' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' export { getBrowserPaneCombinedSearchEntries } type BrowserPaneProps = { @@ -204,7 +205,7 @@ export function BrowserPane({ if (!el) { return } - el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + el.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'start' }) }) }) } diff --git a/src/renderer/src/components/settings/TerminalThemePicker.tsx b/src/renderer/src/components/settings/TerminalThemePicker.tsx index f37d3a5492e..805eb6700b6 100644 --- a/src/renderer/src/components/settings/TerminalThemePicker.tsx +++ b/src/renderer/src/components/settings/TerminalThemePicker.tsx @@ -11,6 +11,7 @@ import { import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { TerminalThemeOption } from '@/lib/terminal-theme' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' type ThemePickerProps = { label: string @@ -45,7 +46,10 @@ export function ThemePicker({ if (!importedHighlightSignal) { return } - importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + importedGroupRef.current?.scrollIntoView({ + behavior: motionSafeScrollBehavior(), + block: 'nearest' + }) setHighlightImported(true) const timer = setTimeout(() => setHighlightImported(false), 2000) return () => clearTimeout(timer) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts index c0830f0e264..09fa7cd2d54 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts @@ -1,4 +1,5 @@ import { GROUP_HEADER_ROW_HEIGHT } from './worktree-list/viewport/virtual-rows' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' const WORKTREE_REVEAL_TOP_CLEARANCE = 6 export const WORKTREE_SIDEBAR_REVEAL_TOP_INSET = @@ -54,16 +55,8 @@ export function revealElementInScrollContainer( if (nextScrollTop === null) { return true } - // Why: honor the user's reduced-motion preference by jumping instantly instead of - // animating a smooth scroll (also makes the reveal deterministic in headless - // environments that never tick the smooth-scroll animation). - const prefersReducedMotion = - typeof window !== 'undefined' && - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true - const resolvedBehavior: ScrollBehavior = - behavior === 'smooth' && prefersReducedMotion ? 'auto' : behavior const targetTop = Math.max(0, nextScrollTop) onScrollIssued?.(targetTop) - container.scrollTo({ top: targetTop, behavior: resolvedBehavior }) + container.scrollTo({ top: targetTop, behavior: motionSafeScrollBehavior(behavior) }) return true } diff --git a/src/renderer/src/components/tab-bar/TabStripScrollIndicator.tsx b/src/renderer/src/components/tab-bar/TabStripScrollIndicator.tsx index 4e586159a7b..2c4bd7e4565 100644 --- a/src/renderer/src/components/tab-bar/TabStripScrollIndicator.tsx +++ b/src/renderer/src/components/tab-bar/TabStripScrollIndicator.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { computeTabStripThumbLayout } from './tab-strip-scroll-metrics' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' export type TabStripScrollIndicatorProps = { hasOverflow: boolean @@ -171,7 +172,7 @@ export function TabStripScrollIndicator({ scrollContainer.scrollTo({ left: targetScrollLeft, - behavior: 'smooth' + behavior: motionSafeScrollBehavior() }) } diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index df1f292bfb4..18eae33270b 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -20,6 +20,7 @@ import { revealTabStripSlot, type ActiveTabDockSide } from './tab-strip-slot-geometry' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -35,7 +36,7 @@ export function scrollTabStripByStep( ) el.scrollBy({ left: direction === 'start' ? -scrollStep : scrollStep, - behavior + behavior: motionSafeScrollBehavior(behavior) }) } diff --git a/src/renderer/src/hooks/usePrefersReducedMotion.test.ts b/src/renderer/src/hooks/usePrefersReducedMotion.test.ts new file mode 100644 index 00000000000..71992728945 --- /dev/null +++ b/src/renderer/src/hooks/usePrefersReducedMotion.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { motionSafeScrollBehavior } from './usePrefersReducedMotion' + +function stubReducedMotion(reduce: boolean): ReturnType { + const matchMedia = vi.fn((query: string) => ({ + matches: reduce && query === '(prefers-reduced-motion: reduce)' + })) + vi.stubGlobal('window', { matchMedia }) + return matchMedia +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('motionSafeScrollBehavior', () => { + it('jumps instead of smooth-scrolling when reduced motion is on', () => { + const matchMedia = stubReducedMotion(true) + + expect(motionSafeScrollBehavior()).toBe('auto') + expect(motionSafeScrollBehavior('smooth')).toBe('auto') + expect(motionSafeScrollBehavior('instant')).toBe('instant') + expect(motionSafeScrollBehavior('auto')).toBe('auto') + expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)') + }) + + it('keeps the requested behavior when reduced motion is off', () => { + stubReducedMotion(false) + + expect(motionSafeScrollBehavior()).toBe('smooth') + expect(motionSafeScrollBehavior('smooth')).toBe('smooth') + expect(motionSafeScrollBehavior('instant')).toBe('instant') + }) + + it('keeps the requested behavior without matchMedia', () => { + expect(typeof window).toBe('undefined') + expect(motionSafeScrollBehavior()).toBe('smooth') + + vi.stubGlobal('window', {}) + expect(motionSafeScrollBehavior('smooth')).toBe('smooth') + expect(motionSafeScrollBehavior('auto')).toBe('auto') + }) +}) diff --git a/src/renderer/src/hooks/usePrefersReducedMotion.ts b/src/renderer/src/hooks/usePrefersReducedMotion.ts index ace2b2083fb..fe09e03a510 100644 --- a/src/renderer/src/hooks/usePrefersReducedMotion.ts +++ b/src/renderer/src/hooks/usePrefersReducedMotion.ts @@ -9,6 +9,11 @@ function readPrefersReducedMotion(): boolean { return window.matchMedia(REDUCED_MOTION_QUERY).matches } +// Why: a scripted scroll's explicit `behavior` is not affected by CSS or the OS setting. +export function motionSafeScrollBehavior(behavior: ScrollBehavior = 'smooth'): ScrollBehavior { + return behavior === 'smooth' && readPrefersReducedMotion() ? 'auto' : behavior +} + export function usePrefersReducedMotion(): boolean { const [prefersReducedMotion, setPrefersReducedMotion] = useState(readPrefersReducedMotion)