feat(app): add reduce motion appearance setting (#57103)

This commit is contained in:
usrnk1
2026-10-19 16:40:55 +02:00
committed by GitHub
Unverified
parent b673e33e32
commit 22ad26f24c
40 changed files with 142 additions and 40 deletions
+2 -2
View File
@@ -178,7 +178,7 @@
transform-origin: 50% 50%;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-slot="titlebar-update-loader"] {
animation: none;
}
@@ -262,7 +262,7 @@
--session-dropzone-card: var(--v2-background-bg-layer-01);
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="session-dropzone"][data-visible="true"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
+2 -1
View File
@@ -1,6 +1,7 @@
import { onCleanup, onMount } from "solid-js"
import { createQuads, type Quads, type Rgba } from "./pin-quads"
import { PinSound } from "./pin-sound"
import { prefersReducedMotion } from "@opencode/ui/motion"
// The wordmark as a 78×14 pin grid: 1 = counter shadow, 2 = "open", 3 = "code".
const GRID = [
@@ -98,7 +99,7 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
const stage = back.parentElement!
// The pins behind the composer and the ones popped in front of it are each one layer of quads.
const layers = { back: createQuads(back, () => wake()), front: createQuads(front, () => wake()) }
const still = matchMedia("(prefers-reduced-motion: reduce)").matches
const still = prefersReducedMotion()
const pins: Pin[] = GRID.flatMap((row, r) =>
[...row].flatMap((ch, c) => (ch === "." ? [] : [createPin(c, r, Number(ch))])),
)
+5
View File
@@ -753,6 +753,11 @@ export const dict = {
"settings.general.row.interfaceMode.inbox.description":
"Every session across your servers, sorted by what needs you next.",
"settings.general.row.interfaceMode.experimental": "Experimental",
"settings.appearance.row.reduceMotion.title": "Reduce motion",
"settings.appearance.row.reduceMotion.description": "Reduce animations or match your system",
"settings.appearance.row.reduceMotion.system": "System",
"settings.appearance.row.reduceMotion.on": "On",
"settings.appearance.row.reduceMotion.off": "Off",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
@@ -56,7 +56,7 @@
margin: 4px -4px;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] & {
animation: none;
}
}
@@ -28,6 +28,7 @@ import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
import { prefersReducedMotion } from "@opencode/ui/motion"
const fallbackItemSize = 60
@@ -869,7 +870,7 @@ export function createTimelineVirtualizer(input: Input) {
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
}}
onClick={() =>
input.onResumeScroll(matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth")
input.onResumeScroll(prefersReducedMotion() ? "auto" : "smooth")
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -1,5 +1,6 @@
import { createEffect, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { prefersReducedMotion } from "@opencode/ui/motion"
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
@@ -16,7 +17,7 @@ export function AnimatedWordmark(props: { active: boolean }) {
timers.forEach(clearTimeout)
timers.clear()
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
if (!active || prefersReducedMotion()) {
setState("letters", [...target])
return
@@ -10,6 +10,12 @@ import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const reduceMotionOptions = [
{ value: "system" as const, label: "settings.appearance.row.reduceMotion.system" as const },
{ value: "on" as const, label: "settings.appearance.row.reduceMotion.on" as const },
{ value: "off" as const, label: "settings.appearance.row.reduceMotion.off" as const },
]
const fontSettings = {
ui: {
action: "settings-ui-font",
@@ -128,6 +134,22 @@ export const SettingsAppearance: Component = () => {
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
<SettingsRow
title={language.t("settings.appearance.row.reduceMotion.title")}
description={language.t("settings.appearance.row.reduceMotion.description")}
>
<Select
data-action="settings-reduce-motion"
options={reduceMotionOptions}
current={reduceMotionOptions.find((option) => option.value === appearance.reduceMotion.current())}
placement="bottom-end"
gutter={6}
value={(option) => option.value}
label={(option) => language.t(option.label)}
onSelect={(option) => option && appearance.reduceMotion.select(option.value)}
/>
</SettingsRow>
</SettingsList>
</div>
</div>
@@ -8,6 +8,7 @@ import {
sansDefault,
sansFontFamily,
sansInput,
type ReduceMotion,
terminalDefault,
terminalFontFamily,
terminalInput,
@@ -90,6 +91,10 @@ export function createAppearanceSettingsController() {
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
},
reduceMotion: {
current: settings.appearance.reduceMotion,
select: (value: ReduceMotion) => settings.appearance.setReduceMotion(value),
},
fonts: {
ui: createMemo(() => ({
value: sansInput(settings.appearance.uiFont()),
+2
View File
@@ -104,6 +104,7 @@ describe("settings schema", () => {
sans: "",
terminal: "",
tabLayout: "horizontal",
reduceMotion: "system",
},
keybinds: {},
permissions: { autoApprove: false, autoApproveProjects: {} },
@@ -150,6 +151,7 @@ describe("settings schema", () => {
sans: "",
terminal: "",
tabLayout: "vertical",
reduceMotion: "system",
})
expect(settings.permissions).toEqual({ autoApprove: true, autoApproveProjects: { on: true, off: false } })
expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } })
+19 -1
View File
@@ -1,5 +1,6 @@
import { produce, reconcile, unwrap } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode/ui/context"
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode/session-ui/timeline/detail"
@@ -17,6 +18,8 @@ export type FollowUpBehavior = Settings["general"]["followUpBehavior"]
export type TabLayout = Settings["appearance"]["tabLayout"]
export type ReduceMotion = Settings["appearance"]["reduceMotion"]
export type NotificationSettings = Settings["notifications"]
export type SoundSettings = Settings["sounds"]
@@ -132,6 +135,7 @@ const appearanceSchema = Persistence.struct({
// "inbox" is experimental: the vertical layout with a server-wide session inbox in place of tabs.
tabLayout: Schema.Literals(["horizontal", "vertical", "inbox"]),
sidebarOpen: Persistence.optional(Schema.Boolean),
reduceMotion: Schema.Literals(["system", "on", "off"]),
})
const permissionsSchema = Persistence.struct({
@@ -287,7 +291,7 @@ export const defaultSettings: Settings = {
mobileTitlebarPosition: "top",
followUpBehavior: "steer",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", reduceMotion: "system" },
keybinds: {},
permissions: { autoApprove: false, autoApproveProjects: {} },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
@@ -330,6 +334,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
const reduceMotion = withFallback(() => store.appearance?.reduceMotion, defaultSettings.appearance.reduceMotion)
const systemReducedMotion = createMediaQuery("(prefers-reduced-motion: reduce)")
// Motion styles key off this attribute instead of the media query so the setting can override the system.
createEffect(() => {
if (typeof document === "undefined") return
const reduce = reduceMotion() === "system" ? systemReducedMotion() : reduceMotion() === "on"
document.documentElement.dataset.motion = reduce ? "reduce" : "full"
})
const autoApproveDefault = withFallback(
() => store.permissions?.autoApprove,
defaultSettings.permissions.autoApprove,
@@ -406,6 +420,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setSidebarOpen(value: boolean) {
setStore("appearance", "sidebarOpen", value)
},
reduceMotion,
setReduceMotion(value: ReduceMotion) {
setStore("appearance", "reduceMotion", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
@@ -106,6 +106,12 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
target: "settings-terminal-font",
keywords: "terminal typeface",
},
{
tab: "appearance",
label: "settings.appearance.row.reduceMotion.title",
target: "settings-reduce-motion",
keywords: "animation accessibility",
},
{
tab: "notifications",
label: "settings.general.notifications.agent.title",
+1 -1
View File
@@ -295,7 +295,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-search-target]::before {
animation-timing-function: steps(1, end);
}
+3 -3
View File
@@ -943,7 +943,7 @@
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.settings-provider-console-chevron {
transition: none;
}
@@ -1459,7 +1459,7 @@
padding-block: 2px;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.settings-models-group-chevron svg,
.provider-model-group-chevron {
transition: none;
@@ -1709,7 +1709,7 @@
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.settings-workspaces-row-motion {
transition: none;
}
@@ -327,7 +327,7 @@
unicode-bidi: isolate;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.project-settings-icon-overlay,
.project-settings-color,
.project-settings-shared-chevron {
+1 -1
View File
@@ -77,7 +77,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
+1 -1
View File
@@ -403,7 +403,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
animation: none;
}
@@ -121,7 +121,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="session-tab-popover"],
[data-component="session-tab-popover"][data-closed] {
animation: none;
@@ -28,7 +28,7 @@
transform-origin: top left;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="browser-comment"],
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
animation: none;
@@ -314,7 +314,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.session-summary-popover,
.session-service-menu {
animation: none;
@@ -312,7 +312,7 @@
transform: none;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] & {
transition: none;
}
}
@@ -441,7 +441,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="markdown"] :is([data-markdown-word], code)[data-markdown-enter] {
animation: none;
}
@@ -179,7 +179,7 @@
background-color 150ms ease-out,
color 150ms ease-out;
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] & {
transition: none;
}
@@ -1411,7 +1411,7 @@
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] & {
transition: none;
}
}
@@ -430,7 +430,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] & {
[data-slot="question-option"],
.question-custom-field[data-component="text-input-v2"] {
transition: none;
@@ -50,7 +50,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] {
transition-duration: 0ms;
}
@@ -93,7 +93,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="tool-count-summary"] [data-slot="tool-count-summary-empty"],
[data-component="tool-count-summary"] [data-slot="tool-count-summary-item"],
[data-component="tool-count-summary"] [data-slot="tool-count-summary-prefix"] {
@@ -76,7 +76,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="tool-status-title"] [data-slot="tool-status-swap"],
[data-component="tool-status-title"] [data-slot="tool-status-tail"] {
transition-duration: 0ms;
@@ -14,7 +14,7 @@
fill: currentColor;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="session-progress-indicator-v2"] [data-frame-content] {
display: none;
}
+15
View File
@@ -52,6 +52,20 @@ const Direction = (props: { value?: unknown }) => {
return null
}
// The app derives this attribute from its reduce motion setting; stories follow the system preference.
const Motion = () => {
onMount(() => {
const query = matchMedia("(prefers-reduced-motion: reduce)")
const apply = () => {
document.documentElement.dataset.motion = query.matches ? "reduce" : "full"
}
apply()
query.addEventListener("change", apply)
onCleanup(() => query.removeEventListener("change", apply))
})
return null
}
const BodyTypography = () => {
onMount(() => {
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
@@ -74,6 +88,7 @@ const frame = createJSXDecorator((Story, context) => {
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
<Motion />
<BodyTypography />
<DialogProvider>
<MarkedProvider>
@@ -88,7 +88,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="animated-number"] [data-slot="animated-number-value"][data-resizing] {
transition-duration: 0ms;
}
+1 -1
View File
@@ -138,7 +138,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="text-reveal"] [data-slot="text-reveal-track"] {
transition-duration: 0ms !important;
}
@@ -20,7 +20,7 @@
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-slot="text-strikethrough-line"] {
clip-path: none !important;
}
+1 -1
View File
@@ -25,7 +25,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="loader-v2"] {
animation: none;
}
+1 -1
View File
@@ -215,7 +215,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
.toast-v2,
.toast-v2 > * {
animation: none !important;
+2 -1
View File
@@ -7,6 +7,7 @@ import { Portal } from "solid-js/web"
import { useI18n } from "../../context/i18n"
import "../../actions/button/button.css"
import "./toast.css"
import { prefersReducedMotion } from "../../motion"
export interface ToastRegionProps extends ToasterProps {}
@@ -307,7 +308,7 @@ function pulseToast(toastId: number) {
requestAnimationFrame(() => {
const element = document.querySelector<HTMLElement>(`[data-testid="toast-v2-${toastId}"]`)
if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
if (!element || prefersReducedMotion()) return
element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" })
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ const frameLoop: {
}
function createFrameLoop(scheduler?: { request(callback: (time: number) => void): number; cancel(id: number): void })
const prefersReducedMotion: () => boolean // live matchMedia
const prefersReducedMotion: () => boolean // root data-motion, else live matchMedia
function observeReducedMotion(listener: () => void): () => void
class VelocityTracker {
+24 -2
View File
@@ -26,7 +26,20 @@ function media(): MediaQueryList | undefined {
return result
}
export const prefersReducedMotion = (): boolean => media()?.matches ?? false
// The app resolves its reduce motion setting into this attribute; without it the system preference applies.
function override(): boolean | undefined {
const value = globalThis.document?.documentElement.getAttribute("data-motion")
if (value === "reduce") return true
if (value === "full") return false
return undefined
}
let attribute: MutationObserver | undefined
export const prefersReducedMotion = (): boolean => override() ?? media()?.matches ?? false
/** Also settle active WAAPI jobs when the preference changes mid-animation. SSR-safe. */
export function observeReducedMotion(listener: () => void): () => void {
@@ -34,11 +47,20 @@ export function observeReducedMotion(listener: () => void): () => void {
if (!preference) return () => {}
if (listeners.size === 0) preference.addEventListener("change", notify)
if (listeners.size === 0) {
preference.addEventListener("change", notify)
if (globalThis.document && globalThis.MutationObserver) {
attribute = new MutationObserver(notify)
attribute.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"] })
}
}
listeners.add(listener)
return () => {
if (!listeners.delete(listener) || listeners.size > 0) return
result?.removeEventListener("change", notify)
attribute?.disconnect()
attribute = undefined
}
}
@@ -16,6 +16,7 @@ import {
type ParentProps,
} from "solid-js"
import "./segmented-control.css"
import { prefersReducedMotion } from "../../motion"
type OnChange = (value: string | null) => void
@@ -108,8 +109,6 @@ export function SegmentedControl(props: SegmentedControlProps) {
const selected = createMemo(() => (isControlled() ? (local.value ?? null) : internal()))
const reducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true
const x = useMotionValue(0)
const width = useMotionValue(0)
@@ -135,7 +134,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
target = next
if (!ready() || !shouldAnimate || reducedMotion()) {
if (!ready() || !shouldAnimate || prefersReducedMotion()) {
x.jump(next.x)
width.jump(next.width)
setReady(true)
+1 -1
View File
@@ -94,7 +94,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="tooltip-v2"],
[data-component="tooltip-v2"][data-closed] {
animation: none;
@@ -18,6 +18,10 @@
@source not "../../../src/**/*.test.{ts,tsx}";
@import "./utilities.css";
/* The app resolves its reduce motion setting, including the system preference, into this attribute. */
@custom-variant motion-reduce (&:where(:root[data-motion="reduce"] *));
@custom-variant motion-safe (&:where(:root:not([data-motion="reduce"]) *));
@import "../index.css";
@theme {
@@ -101,7 +101,7 @@
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-motion="reduce"] {
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
transition-duration: 0ms;