feat(accessibility): gate splash scanline + cursor pulse on reduce-motion

Resume-prompt Option E, part 1 of 2 (the reduce-motion piece;
HC chrome borders follow in a separate commit).

v0.21.1 wired `Settings::reduce_motion_mode` through
`effective_slide_secs` so cards snap instead of sliding under
reduce-motion. The design-system spec at §Accessibility (#3)
calls out two more sources of non-essential motion that
reduce-motion should suppress: the splash CRT scanline effect
and the splash cursor pulse. This commit gates both.

### Splash cursor pulse (`pulse_splash_cursor`)

Previously sine-pulsed every frame regardless of settings. Now
reads `Settings::reduce_motion_mode` and skips the pulse
multiplier when on — the cursor still fades in / out with the
global splash alpha (essential timing), but doesn't blink
(decorative motion). The fade is preserved on purpose: skipping
it would hard-cut the splash on/off, which is jarring; the spec
specifically calls out *non-essential* motion as the reduce-
motion target, and a decorative blink is more clearly
non-essential than a fade timeline.

### Splash scanline overlay (`spawn_splash`)

Previously generated and spawned unconditionally when
`Assets<Image>` was available. Now skipped entirely when
reduce-motion is on — without the scanline overlay the boot
screen still reads as terminal-themed (foreground content,
borders, palette swatches all unchanged); the scanlines are
purely decorative.

### Test

New `splash_skips_scanline_overlay_under_reduce_motion` pins
the gate behaviour: under `reduce_motion_mode = true`, the
splash root still spawns (essential motion intact) but the
`SplashScanlineOverlay` entity is absent. 1193 passing
(+1 from prior 1192).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 13:07:51 -07:00
parent 279a834f9d
commit ed152e2d8f
+72 -3
View File
@@ -219,12 +219,27 @@ fn spawn_splash(
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Settings is borrowed twice — once for the first_run_complete
// gate above, once here for the reduce-motion gate. The borrow
// above already happened (and was let-go via the `settings.as_deref()`
// pattern's auto-drop), so this re-read is safe.
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
// Generate the scanline texture handle up-front (when the asset // Generate the scanline texture handle up-front (when the asset
// store is available — always true in production; opt-out under // store is available — always true in production; opt-out under
// bare `MinimalPlugins` test fixtures so existing tests that // bare `MinimalPlugins` test fixtures so existing tests that
// don't init `Assets<Image>` keep working with the rest of the // don't init `Assets<Image>` keep working with the rest of the
// splash content unchanged). // splash content unchanged). Also skipped when reduce-motion is
let scanline_handle = images.map(|mut images| images.add(build_scanline_image())); // on — the scanline overlay is the "CRT scanline effect" the
// design-system spec calls out as non-essential motion under
// reduce-motion (`design-system.md` §Accessibility #3). Without
// it the boot screen still reads as terminal-themed; the
// scanlines are decorative.
let scanline_handle = if reduce_motion {
None
} else {
images.map(|mut images| images.add(build_scanline_image()))
};
commands commands
.spawn(( .spawn((
@@ -712,15 +727,29 @@ fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 {
/// ///
/// No-op when no `SplashRoot` exists (the splash has already /// No-op when no `SplashRoot` exists (the splash has already
/// despawned, or we're under a test fixture that doesn't spawn one). /// despawned, or we're under a test fixture that doesn't spawn one).
///
/// Under `Settings::reduce_motion_mode`, the per-frame pulse
/// multiplier is skipped — the cursor still fades in / out with
/// the global splash alpha (essential timing) but doesn't blink
/// (decorative motion). Spec at `design-system.md` §Accessibility
/// (#3): reduce-motion suppresses non-essential motion only;
/// fade-in / fade-out timelines stay intact because the splash
/// itself would otherwise hard-cut on/off, which is jarring.
fn pulse_splash_cursor( fn pulse_splash_cursor(
roots: Query<&SplashAge, With<SplashRoot>>, roots: Query<&SplashAge, With<SplashRoot>>,
settings: Option<Res<SettingsResource>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>, mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) { ) {
let Some(age) = roots.iter().next() else { let Some(age) = roots.iter().next() else {
return; return;
}; };
let global = splash_alpha(age.0).unwrap_or(0.0); let global = splash_alpha(age.0).unwrap_or(0.0);
let pulse = cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN); let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
let pulse = if reduce_motion {
1.0
} else {
cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN)
};
let combined = (global * pulse).clamp(0.0, 1.0); let combined = (global * pulse).clamp(0.0, 1.0);
for (fadable, mut bg) in &mut pulses { for (fadable, mut bg) in &mut pulses {
let mut c = fadable.base_color; let mut c = fadable.base_color;
@@ -954,6 +983,46 @@ mod tests {
); );
} }
#[test]
fn splash_skips_scanline_overlay_under_reduce_motion() {
// The CRT scanline overlay is decorative motion that
// `Settings::reduce_motion_mode` suppresses per the
// design-system spec (§Accessibility #3). The splash
// root itself still spawns — the cursor still fades in
// and out (essential timing), but the scanline overlay
// node is omitted entirely.
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default())
.init_asset::<bevy::image::Image>()
.add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.insert_resource(SettingsResource(Settings {
first_run_complete: false,
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// The splash root spawns (essential motion intact)
assert_eq!(
count_splash_roots(&mut app),
1,
"splash should still spawn under reduce-motion — only the scanline + pulse are gated",
);
// The scanline overlay is gone
let scanline_count = app
.world_mut()
.query::<&SplashScanlineOverlay>()
.iter(app.world())
.count();
assert_eq!(
scanline_count, 0,
"scanline overlay must NOT spawn under reduce-motion",
);
}
#[test] #[test]
fn splash_despawns_after_total_duration() { fn splash_despawns_after_total_duration() {
let mut app = headless_app(); let mut app = headless_app();