Skip to content

feat(config,client): OS reduced-motion detection with tri-state animations.enabled (UI/UX v3 P3c) - #84

Merged
mizu-jun merged 10 commits into
masterfrom
p3c-reduced-motion
Aug 29, 2026
Merged

feat(config,client): OS reduced-motion detection with tri-state animations.enabled (UI/UX v3 P3c)#84
mizu-jun merged 10 commits into
masterfrom
p3c-reduced-motion

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

UI/UX v3 P3c is the switch that turns the motion language off for people who asked their OS to stop moving things. P3a built the timing primitives (#77, #78), P3b1–P3b3 applied them to surfaces, hover and press (#79, #80, #82, #83); this makes all of it respect the OS accessibility preference.

Design: docs/superpowers/specs/2026-08-29-p3c-reduced-motion-design.md
Plan: docs/superpowers/plans/2026-08-29-p3c-reduced-motion.md

The shape, and why no animation code changed

animations.enabled becomes tri-state and AnimationsConfig carries the OS state itself:

pub enum AnimationsEnabled { Auto, Yes, No }   // serde: "auto" / true / false

pub struct AnimationsConfig {
    pub enabled: AnimationsEnabled,
    pub intensity: AnimationIntensity,
    #[serde(skip)]
    os_reduced_motion: bool,   // private; stamped by the platform layer
}

effective_multiplier() returns 0 for No, or for Auto when the OS flag is set. Every animation in this client — surface open/close, hover cross-fade, press pulse, tab accent — reaches its duration through scaled_duration_ms, so all ~70 call sites and every animation type are untouched. The switch lives in one place because that is where the multiplier already lived.

Three properties are structural rather than maintained by care:

  • Detection can only ever disable. There is no path from os_reduced_motion to a larger multiplier.
  • A detection failure changes nothing. None from the platform layer means "cannot tell" and is read as "not reduced".
  • The OS value cannot reach config.toml. The field is private and #[serde(skip)]; the settings panel writes the file back through toml_edit, and a serializable OS value would persist a setting the user never chose. The privacy also makes AnimationsConfig { .. ..Default::default() } illegal outside the crate, which is how we know the seal holds.

enabled = true means "animate anyway" — an OS-wide preference is not always what someone wants inside a terminal. enabled = false still means never. Pre-P3c configs keep parsing; the default changes from true to auto, which is the point of the phase and only ever removes motion.

Detection

Platform Source New dependency
Windows SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION) — reports whether animations are enabled, so reduced motion is its negation none (windows-sys already present, feature already enabled)
macOS NSWorkspace.accessibilityDisplayShouldReduceMotion objc2 core only — deliberately not the objc2-app-kit subtree for one BOOL
Linux none

Sampled at startup and on WindowEvent::Focused(true). Focus-gain rather than a native change notification because it matches what the user does — open System Settings, change the preference, come back — and needs no observer machinery on either platform. ThemeChanged is the existing precedent for reacting to an OS preference here.

Correction to the v3 plan: it said macOS detection would "share the objc2 dependency decision with P2". That was stale — P2c added window-vibrancy, which does not expose this preference. P3c made its own call.

Two traps this phase had to be built around

A config hot-reload silently un-does it. Reloading builds a fresh Config whose OS flag starts unset, so saving config.toml would have restored animations the OS asked us to stop. The sampled value is now carried across explicitly (lifecycle.rs, before the new config replaces the old).

The settings row was correct on screen and wrong to a screen reader. The auto value shows how it resolves — "Auto (normal)" / "Auto (reduced)" — because a row reading just "auto" implies animations might be on when they are all off. But accessibility.rs and the keyboard-navigation path call the shared widget-description path directly, bypassing the render path where the real value was patched in, so AccessKit announced "Auto (normal)" under OS reduced motion. On a feature that exists for accessibility, being wrong for exactly that user is the defect that matters most. Fixed by giving SettingsPanel its own mirror of the sampled value, stamped at the same three sites, so every consumer reads one source.

Test coverage

  • nexterm-config — the truth table (Auto + OS reduced → 0; Yes overrules the OS; No stays 0 whatever the OS says), backward-compatible parsing of the old booleans, the default, and that the OS flag never serializes.
  • nexterm-client-gpu — the settings row cycles both directions, the auto label distinguishes its two resolutions, each state writes back its own TOML spelling, and the shared descriptor path (the one AccessKit reads) reports the right label.
  • 1035 tests in nexterm-client-gpu; cargo test --workspace green; cargo clippy --workspace --all-targets --all-features -- -D warnings and cargo fmt --all -- --check clean.

platform::reduced_motion() itself has no unit test, deliberately: it is one FFI call per platform with no branching worth pinning, and CI cannot set an OS accessibility preference. Keeping it thin is what makes that acceptable — every decision lives above it, where the tests are.

Known limitations

  • Linux has no detection. auto animates there; the manual setting stays the documented fallback. GNOME's enable-animations and the XDG settings portal are both plausible later.
  • No native change notification. A preference changed while the window already has focus is not noticed until focus is lost and regained.
  • The macOS arm is compiled but unverified outside CI. It rests on objc2's runtime class lookup finding NSWorkspace, which holds because winit and window-vibrancy link AppKit. If that turns out false, the fallback is objc2-app-kit — a dependency swap, not a redesign.
  • Neither platform's detection is verified on a real machine. The config layer is unit-tested; that the OS reports what we think it reports is a two-minute manual check per platform, on the existing on-device verification backlog.

Test plan

  • cargo test --workspace
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • Windows: turn off "Show animations in Windows", focus the terminal, confirm every animation is instant and the settings row reads "Auto (reduced)"
  • macOS: turn on "Reduce motion", same check
  • Either platform: set enabled = true with the OS preference on, confirm animations still run
  • Either platform: with the OS asking for reduced motion, save config.toml and confirm animations stay off (the hot-reload carry-over)

animations.enabled を tri-state 化し、OS 由来の値は #[serde(skip)] の
内部フラグとして AnimationsConfig に持たせる。これで約 70 箇所の
読み出しと全アニメーションのコードを一切変えずに reduced motion が
効く。計画書が古くなっていた点(macOS は P2 の objc2 判断を共有、
という記述)も訂正した。
5 タスク(config の 3 値化 → OS 検出 → 反映 → 設定パネル → 文書)。
計画中に見つけた罠を明記した: config のホットリロードは Config を
作り直すため、OS 状態を引き継がないと config.toml を編集した瞬間に
アニメーションが復活する。
…flag

auto / true / false の 3 値にし、OS 由来の状態は #[serde(skip)] の
非公開フィールドとして持つ。乗数の計算は AnimationsConfig 内に
閉じているため、約 70 箇所の読み出しとアニメーション側のコードは
変更不要。既存の enabled = true / false もそのまま読める。
Windows は既存の windows-sys で SPI_GETCLIENTAREAANIMATION を読む
(追加依存なし)。macOS は objc2 コアのみで NSWorkspace を叩き、
objc2-app-kit のサブツリーは入れない。Linux は None を返す。
判定不能は「抑制なし」として扱うため、検出失敗が動きを勝手に
止めることはない。
ネイティブの変更通知は使わず、フォーカス獲得時に読み直す。
「システム設定を開いて変更 → 戻ってくる」という実際の操作を
そのまま拾えるうえ、両 OS で同じ形になる。config のホット
リロードは新しい Config を作り直すため、OS 状態の引き継ぎを
明示的に行う。
auto / on / off を設定パネルから選べるようにした。auto は
現在どちらに解決されているか(通常 / 動きを抑制)を併記する。
併記しないと、OS が抑制を要求している環境で行の表示が
実態と食い違うため。8 ロケール全部に文字列を追加した。
window_widget_descs(AccessKitと矢印キーナビゲーションが直接読む共有ディスクリプタ経路)
がauto行のOS reduced-motion値をfalse固定にしていたため、OSが縮小モーションを要求していても
スクリーンリーダーが「Auto (normal)」と読み上げてしまう不具合を修正。

SettingsPanelにanimations_os_reduced(AnimationsConfig::os_reduced_motion()のミラー、
animations_enabledと同じパターン)を追加し、set_os_reduced_motionを呼ぶ全箇所
(起動時サンプリング・フォーカス取得時の再サンプリング・ホットリロード時の引き継ぎ)で
同期する。settings_window.rsのkind()はこのフィールドを直接読むようになったため、
build_window_widgets側の後付けパッチとそれ専用のanimations_os_reducedパラメータ
(呼び出し元まで連鎖していた)を削除した。

回帰テストとしてanimations_row_label_reflects_the_panels_os_reduced_fieldを追加。
window_widget_descsを直接呼び、修正前は失敗することを確認済み。
CONFIGURATION.md に 3 値の意味と「検出は無効化方向にしか働かない」
「OS 由来の値は config.toml に書き戻さない」を明記。計画書の P3c を
完了にし、macOS の依存判断が P2 から独立したことも記録した。
3つのテストが SettingsPanel::default() 直後に animations_enabled = Auto を
代入していたが、AnimationsEnabled のデフォルトは既に Auto であり冗長な代入
だった。cargo clippy --workspace --all-targets --all-features -- -D warnings
がこの3箇所でエラーになっていたため削除。
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

Windows/macOS の CI が needless_return で失敗していた。1 つの関数本体に
複数の #[cfg] ブロックを収めていたため、Linux では最後のブロックだけが
残り return を含まないが、Windows/macOS では自プラットフォームの
return が関数末尾に残ってしまい clippy に検出された。Linux 単独の
ローカル検証では再現しない構造だったので、#[cfg] ごとに独立した関数
定義へ分割し、各 tail 式から return を除去して構造的に解消した。
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

@mizu-jun
mizu-jun merged commit e667c12 into master Aug 29, 2026
12 checks passed
@mizu-jun
mizu-jun deleted the p3c-reduced-motion branch August 29, 2026 05:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant