//! Launch splash overlay. //! //! On app start the engine spawns a fullscreen, high-Z overlay that //! reads the Terminal-style "boot screen" — an accent-coloured cursor block, the //! "Ferrous Solitaire" wordmark, a short fixture boot log, a progress //! bar, and a footer with the design-system palette swatches and the //! build version. The overlay fades in over 300 ms, holds for ~1 s, //! then fades out for 300 ms before despawning. The deal animation //! plays *behind* the splash during the hold, so the player sees the //! dealt board appear as the splash dissolves. //! //! ## Why an overlay instead of an `AppState` //! //! Every existing plugin in this engine runs unconditionally on //! `Startup`/`Update`; gating them with `run_if(in_state(...))` would //! be a sweeping refactor for a one-off brand beat. The splash //! instead sits on top of `Z_SPLASH` (above tooltips, focus ring, //! and toasts) while the rest of the game runs normally beneath it. //! The handoff is intentional: the user finishes the splash and the //! dealt board is already there. //! //! ## Dismissal //! //! Any keypress, mouse click, or touch begin shortcuts the splash to //! its fade-out window — never to an instant despawn, so the dissolve //! still plays for visual continuity. The dismiss input is **not** //! consumed, so a player who instinctively taps Space to "skip the //! intro" still gets their stock draw the moment the splash clears //! (Space and most other gameplay keys read `just_pressed`, which by //! the next tick is already false — splash dismissal happens on the //! same tick as the press, so downstream gameplay handlers see //! exactly the keystroke they would have seen with no splash). //! //! ## Fade scaffold //! //! Every visible element on the splash carries a [`SplashFadable`] //! (text colour) or [`SplashFadableBg`] (background colour) marker //! that records its full-alpha base colour. [`advance_splash`] reads //! `SplashAge` once per frame, computes the current alpha, and writes //! `base_color` × current-alpha into every fadable. Replaces the //! prior per-marker queries (`SplashTitle` / `SplashSubtitle` / //! `SplashCursor`) which didn't scale past three children — the //! Terminal splash has ~15 fadable elements (cursor, title, divider, //! subtitle, four boot-log rows, progress-bar track + fill, //! progress-bar caption, palette label, eight palette swatches, //! version line). //! //! The trailing "▌ ready_" cursor pulse layers on top of the fade //! by carrying both [`SplashFadableBg`] and [`SplashCursorPulse`]: //! [`pulse_splash_cursor`] runs after [`advance_splash`] in the //! schedule chain and overwrites the cursor's `BackgroundColor` //! with `global_alpha × pulse_factor`. Multiplying keeps the pulse //! visually anchored to the global timeline — no fight, just a //! modulated signal on top of the master volume. //! //! ## Headless tests //! //! Under `MinimalPlugins + SplashPlugin`, the `Time` clock //! clamps each tick to `max_delta` (default 250 ms) regardless of //! the `TimeUpdateStrategy::ManualDuration` value, so tests advance //! time in 200 ms ticks and call `app.update()` enough times to //! cross the desired threshold (same approach used by //! `ui_tooltip::tests`). use std::time::Duration; use bevy::asset::RenderAssetUsages; use bevy::image::Image; use bevy::input::touch::Touches; use bevy::prelude::*; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::ui::widget::NodeImageMode; use crate::font_plugin::FontResource; use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ ACCENT_PRIMARY, ACCENT_SECONDARY, BG_BASE, BORDER_SUBTLE, MOTION_SPLASH_FADE_SECS, MOTION_SPLASH_TOTAL_SECS, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH, }; // --------------------------------------------------------------------------- // Public plugin // --------------------------------------------------------------------------- /// Drives the launch splash overlay. Add this plugin once at app /// start; the splash spawns during `Startup`, fades in/out over /// [`MOTION_SPLASH_TOTAL_SECS`], and despawns itself. /// /// The overlay is a sibling of every other UI surface — it never /// becomes a parent of game systems, and the deal animation runs /// underneath it during the hold window. Dismissal on any keypress /// / click / touch shortcuts the timeline into the fade-out phase /// rather than despawning instantly, so the dissolve always plays. pub struct SplashPlugin; impl Plugin for SplashPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, spawn_splash).add_systems( Update, ( dismiss_splash_on_input, advance_splash, pulse_splash_cursor, ) .chain(), ); } } /// Period of the trailing "▌ ready_" pulse cursor, in seconds. ~1 s /// reads as a comfortable terminal-blink cadence — much faster reads /// as urgent (alarming on a hold-and-fade screen), much slower reads /// as listless. Held as a `const` rather than a token because it's /// splash-local: no other surface pulses on this rhythm. const MOTION_PULSE_PERIOD_SECS: f32 = 1.0; /// Floor for the pulse alpha multiplier. The cursor never extinguishes /// fully — matches a real terminal blink that dips but stays visible /// so the player keeps a stable focal point. const PULSE_ALPHA_MIN: f32 = 0.4; // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Marker on the splash overlay scrim (root entity for the launch beat). /// Despawned with descendants once [`MOTION_SPLASH_TOTAL_SECS`] elapses /// or once a user-input dismissal advances the timeline past the hold. #[derive(Component, Debug)] pub struct SplashRoot; /// Tracks the splash's elapsed visible duration. Stored as a component /// on the splash root rather than a global resource so despawning the /// splash root removes its state along with it — there's no second-run /// concern (the splash is one-shot at app start) and a component keeps /// the splash data co-located with its entity. #[derive(Component, Debug, Default)] pub struct SplashAge(pub Duration); /// Marks a `Text` entity whose `TextColor` should fade with the splash /// timeline. `base_color` is the full-alpha target colour written by /// [`advance_splash`]; the system multiplies its alpha by the current /// fade factor each tick. #[derive(Component, Debug, Clone, Copy)] struct SplashFadable { base_color: Color, } /// Marks a `Node` entity whose `BackgroundColor` should fade with the /// splash timeline. Same contract as [`SplashFadable`] but for nodes /// whose visible colour lives on the background, not on text — palette /// swatches, the progress bar track, and the progress bar fill. #[derive(Component, Debug, Clone, Copy)] struct SplashFadableBg { base_color: Color, } /// Marks the trailing pulse cursor on the "▌ ready_" line. Carries /// `SplashFadableBg` too so it picks up the global fade-in / hold / /// fade-out timeline; [`pulse_splash_cursor`] runs *after* /// [`advance_splash`] in the chain and overwrites the /// `BackgroundColor` with the global alpha multiplied by a /// sine-driven pulse factor in `[PULSE_ALPHA_MIN..1.0]`. Multiplying /// (rather than the pulse system being the only writer) keeps the /// cursor visually anchored to the global timeline — it can't pulse /// at full alpha while the rest of the splash is still fading in. #[derive(Component, Debug)] struct SplashCursorPulse; /// Marks an [`ImageNode`] whose `color` tint should fade with the /// global splash timeline. The per-tick write is `tint = (1, 1, 1, /// global_alpha)`, so the GPU composite is `texture_α × global_α` — /// per-pixel transparency in the texture (e.g. the 30 %-alpha /// scanline rows) is preserved while the whole image still fades /// in / out with the splash. The alternative of cramming the alpha /// into [`SplashFadableBg`] doesn't work because that writer /// *overwrites* the base-colour alpha rather than multiplying it. #[derive(Component, Debug)] struct SplashFadableImage; /// Marker on the fullscreen scanline overlay. Distinct from /// [`SplashFadableImage`] so tests can locate the overlay without /// scanning every fadable image (there's only ever one, but the /// marker makes the query intent explicit). #[derive(Component, Debug)] struct SplashScanlineOverlay; // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- /// Spawns the splash overlay at `Startup`. Builds a fullscreen scrim /// at alpha 0 (so the first paint is invisible — the first /// `advance_splash` tick lifts every fadable's alpha), composes the /// header / boot-log / progress / footer hierarchy, and tags every /// visible child with [`SplashFadable`] or [`SplashFadableBg`] so the /// per-frame fade has a uniform target list. /// /// **Skipped on subsequent launches.** If `SettingsResource` reports /// `first_run_complete == true`, the player has already seen the /// brand beat at least once and we go straight to gameplay — having /// to wait 1.6 s on every launch wears thin fast. The splash still /// shows on first run, after a save reset (settings.json deleted), /// and under `MinimalPlugins` (no `SettingsResource` registered) so /// the test fixture observes the same spawn it always did. fn spawn_splash( mut commands: Commands, font_res: Option>, settings: Option>, images: Option>>, ) { if let Some(settings) = settings.as_deref() && settings.0.first_run_complete { return; } 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 // store is available — always true in production; opt-out under // bare `MinimalPlugins` test fixtures so existing tests that // don't init `Assets` keep working with the rest of the // splash content unchanged). Also skipped when reduce-motion is // 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 .spawn(( SplashRoot, SplashAge(Duration::ZERO), Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, // SpaceBetween distributes the three top-level groups // (header / centre / footer) so the header sits near // the top, the centre column floats in the middle of // the viewport, and the footer hugs the bottom edge — // mirroring the mockup's `justify-between` body. justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::axes(VAL_SPACE_5, VAL_SPACE_7), ..default() }, BackgroundColor(scrim_with_alpha(0.0)), GlobalZIndex(Z_SPLASH), )) .with_children(|root| { spawn_header_section(root, &font_handle); spawn_centre_section(root, &font_handle); spawn_footer_section(root, &font_handle); // Scanline overlay sits last so it renders on top of the // boot-screen content. Absolute-positioned to fill the // root; `NodeImageMode::Tiled` repeats the 2×2 source // texture across the whole viewport. if let Some(handle) = scanline_handle { root.spawn(( SplashScanlineOverlay, SplashFadableImage, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, ImageNode { image: handle, // Start fully transparent so the very first // frame matches every other fadable; the // first `advance_splash` tick lifts this to // `(1, 1, 1, global_alpha)`. color: Color::srgba(1.0, 1.0, 1.0, 0.0), image_mode: NodeImageMode::Tiled { tile_x: true, tile_y: true, stretch_value: 1.0, }, ..default() }, )); } }); } /// Pure helper — builds the 2×2 source texture for the scanline /// overlay. Top row is fully transparent; bottom row is `#1a1a1a` at /// ~30 % alpha (76 / 255 ≈ 0.298). Tiled across the splash by /// `NodeImageMode::Tiled`, the result is a 2 px-pitch horizontal /// scanline pattern at the alpha called for in the mockup. /// /// The tilable unit is 2 px tall (one transparent, one tinted) by /// any width — 2 px wide here is the minimum that still satisfies /// `RenderAssetUsages::RENDER_WORLD`'s validation; the GPU samples /// the same column for every horizontal position. fn build_scanline_image() -> Image { // Per-pixel RGBA bytes. Order is row-major top-to-bottom. let pixels: Vec = vec![ // Row 0: transparent. 0, 0, 0, 0, 0, 0, 0, 0, // Row 1: #1a1a1a at ~30 % alpha (26, 26, 26, 76). 26, 26, 26, 76, 26, 26, 26, 76, ]; // 2 × 2 pixels × 4 bytes per RGBA8 pixel = 16 bytes. Hard-coded // because `TextureFormat::pixel_size()` returns a `Result` in this // Bevy version and a `debug_assert_eq!` shouldn't carry the // unwrap noise. debug_assert_eq!( pixels.len(), 16, "scanline pixel buffer must be 2x2 RGBA8", ); Image::new( Extent3d { width: 2, height: 2, depth_or_array_layers: 1, }, TextureDimension::D2, pixels, TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::RENDER_WORLD, ) } /// Header section: cursor block, wordmark, divider, "TERMINAL EDITION" /// label. Stacked vertically and centre-aligned. Renders near the top /// of the viewport thanks to the root's `justify-between`. fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle) { let cursor_font = TextFont { font: font_handle.clone(), // Larger than TYPE_DISPLAY so the cursor block reads as the // signature element above the wordmark. Hand-tuned literal — // a one-off display character outside the regular text scale. font_size: 96.0, ..default() }; let title_font = TextFont { font: font_handle.clone(), font_size: TYPE_DISPLAY, ..default() }; let subtitle_font = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, row_gap: VAL_SPACE_2, margin: UiRect::top(VAL_SPACE_6), ..default() }) .with_children(|hdr| { hdr.spawn(( SplashFadable { base_color: ACCENT_PRIMARY }, Text::new("\u{258C}"), // ▌ — the Terminal cursor block. cursor_font, TextColor(transparent(ACCENT_PRIMARY)), )); hdr.spawn(( SplashFadable { base_color: TEXT_PRIMARY }, Text::new("Ferrous Solitaire"), title_font, TextColor(transparent(TEXT_PRIMARY)), )); // Thin horizontal divider under the wordmark — same hue as // every other 1px chrome line in the design system. hdr.spawn(( SplashFadableBg { base_color: BORDER_SUBTLE }, Node { width: Val::Px(192.0), height: Val::Px(1.0), ..default() }, BackgroundColor(transparent(BORDER_SUBTLE)), )); hdr.spawn(( SplashFadable { base_color: TEXT_DISABLED }, Text::new("TERMINAL EDITION"), subtitle_font, TextColor(transparent(TEXT_DISABLED)), )); }); } /// Centre section: boot log + progress bar. The boot-log column is /// capped at 480 px on desktop per `docs/ui-mockups/desktop-adaptation.md` /// (otherwise 70 % of viewport width). The progress bar is capped at /// 720 px likewise. fn spawn_centre_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle) { let line_font = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, row_gap: VAL_SPACE_5, ..default() }) .with_children(|centre| { spawn_boot_log(centre, &line_font); spawn_progress_bar(centre, &line_font); }); } /// Boot-log column: three lime check rows + a "▌ ready_" line. Content /// is fixture text, not driven from real bootstrap state — the splash /// is a brand beat, not a real loader. Capped at 480 px width on /// desktop (the design-system spec calls 70 % of mobile viewport, /// which would stretch oddly on a wide window). fn spawn_boot_log(parent: &mut ChildSpawnerCommands, line_font: &TextFont) { parent .spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Start, row_gap: VAL_SPACE_1, width: Val::Percent(70.0), max_width: Val::Px(480.0), ..default() }) .with_children(|log| { for label in ["assets loaded", "theme: terminal", "progress restored"] { spawn_check_row(log, line_font, label); } spawn_ready_row(log, line_font); }); } /// One ✓-prefixed boot-log line. The check glyph is lime /// (`STATE_SUCCESS`) so it reads as "complete"; the description text /// is `TEXT_DISABLED` (the muted gray rung) so the eye treats the /// list as background log noise rather than information that needs /// reading. fn spawn_check_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont, label: &str) { parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|row| { row.spawn(( SplashFadable { base_color: STATE_SUCCESS }, Text::new("\u{2713}"), // ✓ line_font.clone(), TextColor(transparent(STATE_SUCCESS)), )); row.spawn(( SplashFadable { base_color: TEXT_DISABLED }, Text::new(label.to_string()), line_font.clone(), TextColor(transparent(TEXT_DISABLED)), )); }); } /// "▌ ready_" line — visual signature of "boot complete, awaiting /// input". The leading `▌` glyph picks up `TEXT_PRIMARY` rather than /// `ACCENT_PRIMARY` so it doesn't compete with the big accent cursor in /// the header; the *trailing* 6×12 px accent pulse Node ([`SplashCursorPulse`]) /// is what carries the "alive, blinking" signal called for by the /// mockup. The pulse's alpha is multiplied with the global fade /// timeline by [`pulse_splash_cursor`] so it never fights the /// fade-in / hold / fade-out flow. fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) { parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, margin: UiRect::top(VAL_SPACE_2), ..default() }) .with_children(|row| { row.spawn(( SplashFadable { base_color: TEXT_PRIMARY }, Text::new("\u{258C} ready_"), // ▌ ready_ line_font.clone(), TextColor(transparent(TEXT_PRIMARY)), )); // Trailing 6×12 accent pulse cursor. Node-with-explicit- // dimensions rather than a `█` text glyph so the size // doesn't drift with the line font; matches the mockup's // 6×12 px spec literally. Pulse animation lives in // `pulse_splash_cursor` for testability. row.spawn(( SplashFadableBg { base_color: ACCENT_PRIMARY }, SplashCursorPulse, Node { width: Val::Px(6.0), height: Val::Px(12.0), ..default() }, BackgroundColor(transparent(ACCENT_PRIMARY)), )); }); } /// Progress bar — a 1 px tall track in `BORDER_SUBTLE` with a 100 %- /// width accent fill, plus a `DONE · 247 ASSETS` caption right-aligned /// below. The "247" is fixture text; the bar is decorative, not a /// real progress signal. Capped at 720 px width on desktop. fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) { parent .spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Stretch, row_gap: VAL_SPACE_2, width: Val::Percent(80.0), max_width: Val::Px(720.0), ..default() }) .with_children(|bar| { // Track. bar.spawn(( SplashFadableBg { base_color: BORDER_SUBTLE }, Node { width: Val::Percent(100.0), height: Val::Px(1.0), ..default() }, BackgroundColor(transparent(BORDER_SUBTLE)), )) .with_children(|track| { // Fill — 100 % of the track width = "complete". track.spawn(( SplashFadableBg { base_color: ACCENT_PRIMARY }, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(transparent(ACCENT_PRIMARY)), )); }); // Caption — right-aligned below the bar. bar.spawn(Node { flex_direction: FlexDirection::Row, justify_content: JustifyContent::FlexEnd, ..default() }) .with_children(|caption| { caption.spawn(( SplashFadable { base_color: TEXT_DISABLED }, Text::new("DONE \u{00B7} 247 ASSETS"), // DONE · 247 ASSETS line_font.clone(), TextColor(transparent(TEXT_DISABLED)), )); }); }); } /// Footer section: "BASE16-EIGHTIES" label, eight palette swatches, /// version line. The swatches are 12 × 12 px coloured squares, one /// per named token — visible signature of the design system. fn spawn_footer_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle) { let footer_font = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, row_gap: VAL_SPACE_3, ..default() }) .with_children(|footer| { footer.spawn(( SplashFadable { base_color: TEXT_DISABLED }, Text::new("BASE16-EIGHTIES"), footer_font.clone(), TextColor(transparent(TEXT_DISABLED)), )); spawn_palette_swatch_row(footer); footer.spawn(( SplashFadable { base_color: TEXT_DISABLED }, Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))), footer_font.clone(), TextColor(transparent(TEXT_DISABLED)), )); }); } /// Eight 12 × 12 px palette squares — one per named design-system /// token (suit-red / warning / success / info / primary / celebration /// / on-surface / outline). The order matches the mockup; the row is /// the visual signature of the palette behind the rest of the UI. fn spawn_palette_swatch_row(parent: &mut ChildSpawnerCommands) { let swatches = [ STATE_DANGER, STATE_WARNING, STATE_SUCCESS, STATE_INFO, ACCENT_PRIMARY, ACCENT_SECONDARY, TEXT_PRIMARY, // `BORDER_STRONG` (`#505050`) is the eighth slot — `outline` // in the design-system token spec, also exposed as // `TEXT_DISABLED` since the two share a hue. Re-using the // existing `TEXT_DISABLED` import keeps the swatch list a // single read. TEXT_DISABLED, ]; parent .spawn(Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_1, ..default() }) .with_children(|row| { for color in swatches { row.spawn(( SplashFadableBg { base_color: color }, Node { width: Val::Px(12.0), height: Val::Px(12.0), ..default() }, BackgroundColor(transparent(color)), )); } }); } /// Returns `BG_BASE` with its alpha multiplied by `factor` (0–1). The /// fade systems lerp this each tick to drive the scrim's dissolve. fn scrim_with_alpha(factor: f32) -> Color { let mut c = BG_BASE; c.set_alpha(factor.clamp(0.0, 1.0)); c } /// Returns `c` with alpha 0. Initial paint colour for every fadable /// element so the very first frame is fully transparent — the next /// `advance_splash` tick lifts the alpha based on `SplashAge`. fn transparent(c: Color) -> Color { let mut out = c; out.set_alpha(0.0); out } /// Computes the splash's per-frame alpha from its age. Three phases: /// /// * `0..fade` — fade-in: `alpha = age / fade`. /// * `fade..total - fade` — hold: `alpha = 1.0`. /// * `total - fade..total` — fade-out: `alpha = (total - age) / fade`. /// * `>= total` — splash is complete; caller despawns the root. /// /// Returns `None` once the timeline is finished, signalling the splash /// should be despawned. fn splash_alpha(age: Duration) -> Option { let age_s = age.as_secs_f32(); let total = MOTION_SPLASH_TOTAL_SECS; let fade = MOTION_SPLASH_FADE_SECS; if age_s >= total { return None; } if age_s < fade { // Fade-in. return Some((age_s / fade).clamp(0.0, 1.0)); } if age_s < total - fade { // Hold. return Some(1.0); } // Fade-out. Some(((total - age_s) / fade).clamp(0.0, 1.0)) } /// Pure helper — computes the pulse alpha multiplier for a given /// `age`, `period`, and `min` floor. Sine-driven smoothing in /// `[min..1.0]`. Returns `1.0` defensively when `period <= 0.0` so a /// misconfigured caller produces a steady (unmodulated) cursor rather /// than a divide-by-zero. /// /// The phase is `age * TAU / period`, which puts the first peak at /// `age = period / 4` and the first trough at `age = period * 3 / 4` — /// both verified by the tests below. fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 { if period <= 0.0 { return 1.0; } let phase = age.as_secs_f32() * std::f32::consts::TAU / period; let normalised = (phase.sin() + 1.0) * 0.5; // map [-1, 1] → [0, 1] min + normalised * (1.0 - min) } /// Per-frame system that overwrites the trailing pulse cursor's /// `BackgroundColor` with the global splash alpha multiplied by the /// pulse factor. Runs *after* [`advance_splash`] in the chain so the /// last writer wins — the cursor's tick output reflects both the /// fade timeline and the pulse, while the rest of the splash gets /// only the fade. /// /// No-op when no `SplashRoot` exists (the splash has already /// 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( roots: Query<&SplashAge, With>, settings: Option>, mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With>, ) { let Some(age) = roots.iter().next() else { return; }; let global = splash_alpha(age.0).unwrap_or(0.0); 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); for (fadable, mut bg) in &mut pulses { let mut c = fadable.base_color; c.set_alpha(combined); bg.0 = c; } } /// Advances every splash root's age by `time.delta()` and updates the /// scrim plus every [`SplashFadable`] / [`SplashFadableBg`] alpha, /// despawning the splash once the timeline finishes. Despawns with /// descendants so the entire hierarchy leaves the world together. /// /// The fadable queries are global (no parent constraint) — the splash /// is a one-shot at app start and is the only owner of these markers, /// so there is no contamination risk from other plugins. #[allow(clippy::type_complexity)] fn advance_splash( mut commands: Commands, time: Res