//! Centralised UI design tokens — colours, typography, spacing, radius, //! z-index hierarchy, and motion durations. //! //! Every UI surface (HUD, modals, popovers, toasts) reads from these //! tokens instead of hardcoding hex codes or magic numbers. The audit //! that produced this module found 40+ scattered colour literals, 12+ //! distinct font sizes, and 8+ hardcoded z-index values across the //! engine; collapsing them into one source of truth keeps the visual //! system coherent and makes future palette swaps a single-file change. //! //! Palette is "Terminal" (base16-eighties) — see //! `docs/ui-mockups/design-system.md` for the full token spec, mockup //! library, and rationale. The tokens are exposed as `pub const` so //! static contexts (default colours on Sprite components, etc.) can use //! them; a future `UiTheme` resource can layer runtime switching on top //! without changing the constant API. use bevy::color::Color; use bevy::math::Vec2; use bevy::prelude::Val; use solitaire_data::AnimSpeed; // --------------------------------------------------------------------------- // Colours — Terminal (base16-eighties): near-black surface ramp with a brick- // red primary accent and lime/lavender/gold/teal/pink semantic accents. // --------------------------------------------------------------------------- /// Window backstop and the default text colour on top of `ACCENT_PRIMARY`. /// Near-black terminal background. `#151515`. pub const BG_BASE: Color = Color::srgb(0.082, 0.082, 0.082); /// Elevated surface — modal cards, popover panels, button backgrounds. /// One step lighter than `BG_BASE` so cards read as a separate plane /// without needing drop shadows. `#202020`. pub const BG_ELEVATED: Color = Color::srgb(0.125, 0.125, 0.125); /// Hovered/highlighted surface — used on button hover and on the /// currently-active row of a popover. `#2a2a2a`. pub const BG_ELEVATED_HI: Color = Color::srgb(0.165, 0.165, 0.165); /// Top elevation step — Secondary button hover, popover currently- /// hovered row. One rung above `BG_ELEVATED_HI`. `#353535`. pub const BG_ELEVATED_TOP: Color = Color::srgb(0.208, 0.208, 0.208); /// Pressed-button surface — sits below `BG_ELEVATED` so a press reads /// as the surface receding rather than rising. `#1a1a1a`. pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.102, 0.102, 0.102); /// Uniform scrim under every modal. Per the Terminal design system, /// modals dim the table aggressively (95% opacity) without blurring, /// to maintain the crisp synthwave-flat aesthetic. `rgba(21, 21, 21, 0.95)`. pub const SCRIM: Color = Color::srgba(0.082, 0.082, 0.082, 0.95); /// Solid fill for the top-of-window HUD band painted by /// `hud_plugin::spawn_hud_band`. Terminal HUD chips are opaque /// `surface-container` panels — no transparency — so the chrome reads /// as a status-line strip rather than a glassy overlay. `#202020`. pub const BG_HUD_BAND: Color = Color::srgba(0.125, 0.125, 0.125, 1.0); /// Primary text — warm off-white. The base16-eighties foreground. /// `#d0d0d0`. pub const TEXT_PRIMARY: Color = Color::srgb(0.816, 0.816, 0.816); /// High-contrast variant of [`TEXT_PRIMARY`] — `#f5f5f5`. Boosted /// luminance for the Settings → Accessibility → High-contrast mode /// toggle. Spec at `design-system.md` §Accessibility (#2). pub const TEXT_PRIMARY_HC: Color = Color::srgb(0.961, 0.961, 0.961); /// Secondary text — captions, hints, muted labels. `#a0a0a0`. pub const TEXT_SECONDARY: Color = Color::srgb(0.627, 0.627, 0.627); /// Disabled text — greyed-out buttons, locked items. `#505050`. pub const TEXT_DISABLED: Color = Color::srgb(0.314, 0.314, 0.314); /// Brick-red primary accent — the CTA colour of the system. Reserved /// for primary actions (Play, Resume, Save), focus rings, and /// selection. `#a54242` (base16-eighties `base08`). Pre-2026-05-08 /// this slot was cyan `#6fc2ef`; the swap was a project-wide /// palette decision recorded in `design-system.md` and the /// SESSION_HANDOFF entry that followed Option D. pub const ACCENT_PRIMARY: Color = Color::srgb(0.647, 0.259, 0.259); /// Brightened `ACCENT_PRIMARY` for hover states on primary buttons. /// Picks up luminance while keeping the same hue. `#c25e5e`. pub const ACCENT_PRIMARY_HOVER: Color = Color::srgb(0.761, 0.369, 0.369); /// Lavender secondary accent — celebratory states (level-up, /// achievement unlocked, streak milestones). Used sparingly so it stays /// special. `#e1a3ee`. pub const ACCENT_SECONDARY: Color = Color::srgb(0.882, 0.639, 0.933); /// Success — foundation completion, valid drop tint, sync OK. Lime /// from base16-eighties. `#acc267`. pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404); /// High-contrast variant of [`STATE_SUCCESS`] — `#c8e862`. Brighter /// lime that maintains the success hue while lifting luminance from /// ~0.51 → ~0.73 so the WIN MOVE scrub-bar marker stands out from /// the bumped notch ticks (`BORDER_SUBTLE_HC` `#a0a0a0`, L≈0.60) in /// high-contrast mode. pub const STATE_SUCCESS_HC: Color = Color::srgb(0.784, 0.910, 0.384); /// Warning — penalty signal, daily-seed expiry countdown, sync-pending /// status. Gold from base16-eighties. **Both** Undo and Recycle /// counters use this when non-zero. `#ddb26f`. pub const STATE_WARNING: Color = Color::srgb(0.867, 0.698, 0.435); /// Danger — rejection shake, illegal placement, sync error. Pink from /// base16-eighties (also doubles as `suit-red` per the design.md /// rationale). `#fb9fb1`. pub const STATE_DANGER: Color = Color::srgb(0.984, 0.624, 0.694); /// Info — neutral system toasts, sync-connected indicator. Teal from /// base16-eighties. `#12cfc0`. pub const STATE_INFO: Color = Color::srgb(0.071, 0.812, 0.753); /// Soft fill colour for the drop-target overlay shown over every legal /// destination pile while the player is dragging a card. Same lime hue /// as `STATE_SUCCESS` (`#acc267`) so the visual language stays /// consistent, but at 10 % alpha so the underlying card faces remain /// fully readable through the wash. pub const DROP_TARGET_FILL: Color = Color::srgba(0.675, 0.761, 0.404, 0.10); /// Outline colour for the drop-target overlay. Matches the /// `STATE_SUCCESS` hue at 75 % alpha so the rectangle border reads /// unmistakably against both the felt and stacked card faces without /// drowning the cards themselves. pub const DROP_TARGET_OUTLINE: Color = Color::srgba(0.675, 0.761, 0.404, 0.75); /// Thickness of the drop-target outline edges, in world-space pixels. pub const DROP_TARGET_OUTLINE_PX: f32 = 3.0; /// Sprite-space `Transform.z` for drop-target overlay entities. Sits /// well above any static card (top stack z is `~1.04`) but well below /// the lifted dragged stack (`DRAG_Z = 500.0` in `input_plugin`) so the /// overlay never occludes the card the player is holding. Distinct from /// the i32 `Z_*` UI-Node tokens above — those are `ZIndex` values for /// `bevy::ui`, while this is a 2D `Sprite` z coordinate. pub const Z_DROP_OVERLAY: f32 = 50.0; /// Background colour of the stock-pile remaining-count chip. /// /// Reuses `BG_ELEVATED_HI` so the chip reads as one rung above the /// translucent stock pile marker without introducing a new palette /// value. The badge sits on the stock corner so the player knows how /// many cards remain before a recycle. pub const STOCK_BADGE_BG: Color = BG_ELEVATED_HI; /// Foreground (text) colour of the stock-pile remaining-count chip. /// /// `ACCENT_PRIMARY` keeps the chip readable against the elevated /// surface background and matches the primary accent already used /// for other "look here" callouts. pub const STOCK_BADGE_FG: Color = ACCENT_PRIMARY; /// Sprite-space `Transform.z` for the stock-pile remaining-count chip. /// /// Sits above the stock pile marker (`Z_PILE_MARKER` = `-1`) and any /// face-down stock cards (which start at `0`), but well below /// [`Z_DROP_OVERLAY`] (`50.0`) so the green drop-target wash always /// renders on top while a card is being dragged. Like `Z_DROP_OVERLAY`, /// this is a 2D `Sprite` z coordinate, not a `bevy::ui` `ZIndex`. pub const Z_STOCK_BADGE: f32 = 30.0; // --------------------------------------------------------------------------- // Card drop-shadow — the subtle dark halo painted beneath every card so the // play surface reads as physical instead of a flat collage of stickers. Idle // values are deliberately low-contrast (small offset, ~25% alpha) so resting // cards feel grounded without competing with focus rings or drop overlays. // Drag values are slightly stronger (further offset, ~40% alpha, larger // halo) so the dragged stack visually "lifts" off the felt. // --------------------------------------------------------------------------- /// RGB base for the per-card drop shadow. Always neutral black — never /// suit-tinted — so the shadow never carries colour information that a /// colour-blind player would rely on to identify a card. Alpha is applied /// separately via [`CARD_SHADOW_ALPHA_IDLE`] / [`CARD_SHADOW_ALPHA_DRAG`]. pub const CARD_SHADOW_COLOR: Color = Color::srgb(0.0, 0.0, 0.0); /// Alpha for the resting-state card shadow. Set to 0 under the Terminal /// design system: depth is achieved through 1px suit-color borders and /// tonal layering, not blur shadows ("no `box-shadow` anywhere" is a /// hard constraint of `docs/ui-mockups/design-system.md`). The shadow /// rendering code path is left in place so a future palette swap can /// re-enable it without touching consumers. pub const CARD_SHADOW_ALPHA_IDLE: f32 = 0.0; /// Alpha for the lifted/dragged card shadow. Set to 0 for the same /// reason as [`CARD_SHADOW_ALPHA_IDLE`]. Drag affordance under the /// Terminal system is the primary-accent focus glow + z-index lift, /// not a deeper shadow. pub const CARD_SHADOW_ALPHA_DRAG: f32 = 0.0; /// World-space pixel offset of the resting-state card shadow relative to /// its parent card centre. Down-and-right matches a soft top-left light /// source — the same convention used by the elevated-surface tones in the /// rest of the palette. pub const CARD_SHADOW_OFFSET_IDLE: Vec2 = Vec2::new(2.0, -3.0); /// World-space pixel offset of the lifted/dragged card shadow. Roughly /// double the idle offset so the parallax reads as "the card is further /// from the table". pub const CARD_SHADOW_OFFSET_DRAG: Vec2 = Vec2::new(4.0, -6.0); /// Padding in pixels added to each axis of the card size when sizing the /// resting-state shadow sprite. The shadow extends slightly past every /// edge of the card so the dark border reads as a halo rather than a /// matte rectangle behind the card. pub const CARD_SHADOW_PADDING_IDLE: Vec2 = Vec2::new(4.0, 4.0); /// Padding added to the card size when sizing the lifted/dragged shadow. /// A slightly larger halo at the drag state reinforces the "lifted off /// the felt" cue alongside the deeper offset and higher alpha. pub const CARD_SHADOW_PADDING_DRAG: Vec2 = Vec2::new(8.0, 8.0); /// Local `Transform.z` for the shadow child sprite, relative to its /// parent `CardEntity`. Slightly negative so the shadow always renders /// below the card itself even though it shares the parent's world z. pub const CARD_SHADOW_LOCAL_Z: f32 = -0.05; /// Subtle border — default popover, card, and idle button outline. /// `outline-variant` from the design system at full alpha; the Terminal /// aesthetic uses solid 1px borders rather than translucent washes. /// `#353535`. pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0); /// High-contrast variant of [`BORDER_SUBTLE`] — `#a0a0a0`. Lifts /// outlines from near-invisible to clearly visible for the /// Settings → Accessibility → High-contrast mode toggle. Spec at /// `design-system.md` §Accessibility (#2): outline jumps from /// `#505050` to `#a0a0a0` so card borders, popover edges, and /// focus rings are legible on low-quality displays / for low- /// vision users. pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0); /// Marker for entities whose [`BorderColor`] should swap to /// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on. /// Tag any UI node where border legibility is accessibility-critical /// — modal panels, popovers, settings rows, focus-ring carriers — /// then add the `apply_high_contrast_borders` system to react to /// settings changes. /// /// `default_color` records the off-state colour the entity was /// spawned with so the system can revert when HC is toggled back /// off. Different sites use different defaults (`BORDER_SUBTLE` for /// idle popover edges, `BORDER_STRONG` for active modal cards) — the /// marker captures whichever one applies at this entity. #[derive(bevy::prelude::Component, Debug, Clone, Copy)] pub struct HighContrastBorder { /// Border colour to use when high-contrast mode is *off* — the /// site's normal idle / active-state colour. pub default_color: bevy::prelude::Color, } impl HighContrastBorder { /// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`. pub const fn with_default(default_color: bevy::prelude::Color) -> Self { Self { default_color } } } /// Marker for entities whose [`BackgroundColor`] should swap to /// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on. /// Parallel to [`HighContrastBorder`] but for sites that paint their /// shape via `BackgroundColor` rather than `BorderColor` — /// `bevy::ui` 1 px decorative strips, tick marks, fine separators /// often render as tiny full-bleed `Node`s, not as borders, so the /// border-marker pattern doesn't apply. /// /// `default_color` records the off-state colour; `hc_color` the on- /// state colour. [`with_default`] fills `hc_color` with /// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the /// standard subtle-border bump can continue using a one-argument /// constructor. [`with_hc`] overrides the HC colour for the rare /// site (currently only the WIN MOVE scrub-bar marker) that needs a /// domain-specific HC variant (`STATE_SUCCESS_HC` instead of a gray). /// /// [`with_default`]: HighContrastBackground::with_default /// [`with_hc`]: HighContrastBackground::with_hc /// [`BackgroundColor`]: bevy::prelude::BackgroundColor #[derive(bevy::prelude::Component, Debug, Clone, Copy)] pub struct HighContrastBackground { /// Background colour to use when high-contrast mode is *off* — /// the site's normal idle / active-state colour. pub default_color: bevy::prelude::Color, /// Background colour to use when high-contrast mode is *on*. /// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`]. /// /// [`with_default`]: HighContrastBackground::with_default pub hc_color: bevy::prelude::Color, } impl HighContrastBackground { /// Convenience constructor — HC colour defaults to /// [`BORDER_SUBTLE_HC`]. pub const fn with_default(default_color: bevy::prelude::Color) -> Self { Self { default_color, hc_color: BORDER_SUBTLE_HC } } /// Constructor for sites whose HC colour differs from the standard /// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// than to a neutral gray. pub const fn with_hc( default_color: bevy::prelude::Color, hc_color: bevy::prelude::Color, ) -> Self { Self { default_color, hc_color } } } /// Strong border — hover outline, focused button, active popover. /// `outline` from the design system. `#505050`. pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0); /// 2 px ring drawn around the focused interactive element. Brick-red /// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible /// against both elevated surfaces and the modal scrim backdrop. /// `rgba(165, 66, 66, 0.85)`. pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85); // --------------------------------------------------------------------------- // Typography scale (px) — 5 rungs replace the prior // 14/15/16/17/18/22/26/28/30/32/40/48 jungle. All UI uses FiraMono via // `FontResource`; sizes carry the hierarchy. // --------------------------------------------------------------------------- /// Display titles — Home, Win Summary, Onboarding header. 40 px. pub const TYPE_DISPLAY: f32 = 40.0; /// Modal / overlay headers. 26 px. pub const TYPE_HEADLINE: f32 = 26.0; /// Primary HUD numbers, button labels, body copy that needs weight. /// 18 px. pub const TYPE_BODY_LG: f32 = 18.0; /// Secondary HUD, body copy, list items. 14 px. pub const TYPE_BODY: f32 = 14.0; /// Hotkey-hint chips, microcopy, dates. 11 px. pub const TYPE_CAPTION: f32 = 11.0; // --------------------------------------------------------------------------- // Spacing scale (px) — 4-multiple rungs. Every padding, margin, and gap // in the engine snaps to one of these. // --------------------------------------------------------------------------- /// 4 px — inline padding, chip gap. pub const SPACE_1: f32 = 4.0; /// 8 px — default gap between row items. pub const SPACE_2: f32 = 8.0; /// 12 px — standard button padding-X, action row gap. pub const SPACE_3: f32 = 12.0; /// 16 px — section gap inside a modal. pub const SPACE_4: f32 = 16.0; /// 24 px — modal card outer padding. pub const SPACE_5: f32 = 24.0; /// 32 px — block separator inside large overlays. pub const SPACE_6: f32 = 32.0; /// 48 px — outer modal margin from the window edge. pub const SPACE_7: f32 = 48.0; /// `Val::Px` form of `SPACE_1`, for ergonomic Node construction. pub const VAL_SPACE_1: Val = Val::Px(SPACE_1); /// `Val::Px` form of `SPACE_2`. pub const VAL_SPACE_2: Val = Val::Px(SPACE_2); /// `Val::Px` form of `SPACE_3`. pub const VAL_SPACE_3: Val = Val::Px(SPACE_3); /// `Val::Px` form of `SPACE_4`. pub const VAL_SPACE_4: Val = Val::Px(SPACE_4); /// `Val::Px` form of `SPACE_5`. pub const VAL_SPACE_5: Val = Val::Px(SPACE_5); /// `Val::Px` form of `SPACE_6`. pub const VAL_SPACE_6: Val = Val::Px(SPACE_6); /// `Val::Px` form of `SPACE_7`. pub const VAL_SPACE_7: Val = Val::Px(SPACE_7); // --------------------------------------------------------------------------- // Border radius (px) // --------------------------------------------------------------------------- /// 4 px — hotkey chip, inline pill. pub const RADIUS_SM: f32 = 4.0; /// 8 px — buttons, popover panels. pub const RADIUS_MD: f32 = 8.0; /// 16 px — modal cards. pub const RADIUS_LG: f32 = 16.0; // --------------------------------------------------------------------------- // Z-index hierarchy — replaces 8+ scattered magic numbers across plugins // (background, pile markers, HUD, several overlay tiers, win cascade, // toasts). Documented order: // // background → pile markers → cards → HUD → HUD popovers → // modal scrim → modal panel → pause → onboarding → // win cascade → toasts (always on top) // --------------------------------------------------------------------------- pub const Z_BACKGROUND: i32 = -10; pub const Z_PILE_MARKER: i32 = -1; /// Base layer for HUD readouts (top-left). pub const Z_HUD: i32 = 50; /// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so /// tapping outside it light-dismisses the panel without blocking other input. pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4; /// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop. pub const Z_HUD_POPOVER: i32 = Z_HUD + 5; /// Transient HUD annotations (score-delta floaters) — above popovers. pub const Z_HUD_TOP: i32 = Z_HUD + 10; pub const Z_MODAL_SCRIM: i32 = 200; pub const Z_MODAL_PANEL: i32 = 210; /// Pause overlay outranks normal modals — pausing should always be on top. pub const Z_PAUSE: i32 = 220; /// Confirmation dialog stacked on top of the pause overlay (e.g. the /// forfeit-confirm modal launched from the pause modal). Sits above /// `Z_PAUSE` so the dialog is always visible over the paused state. pub const Z_PAUSE_DIALOG: i32 = 225; pub const Z_ONBOARDING: i32 = 230; /// Z-layer for the keyboard focus indicator. Sits one rung above the /// topmost modal layer (`Z_ONBOARDING`) so the ring is never occluded by /// a modal card's hover state, while staying below the win cascade and /// transient toasts that are allowed to overlay everything else. pub const Z_FOCUS_RING: i32 = 240; /// Win cascade sits between modals and toasts so the celebration plays /// over a paused / mid-modal screen. pub const Z_WIN_CASCADE: i32 = 300; /// Toasts always render above everything else. pub const Z_TOAST: i32 = 400; // --------------------------------------------------------------------------- // Motion — durations in seconds at `AnimSpeed::Normal`. `Fast` halves // every value, `Instant` zeroes them. Use `scaled_duration` to apply. // --------------------------------------------------------------------------- /// Card slide during gameplay — tweened with `MotionCurve::SmoothSnap` /// (ease-out-cubic). 180 ms; bumped from the old 150 ms because cubic /// feels slower at endpoints — the perceived speed is unchanged. pub const MOTION_SLIDE_SECS: f32 = 0.18; /// Settle bounce on placement — only the moved card, not every top /// card on every state change. 180 ms. pub const MOTION_SETTLE_SECS: f32 = 0.18; /// Shake on rejected drop — tightened from 300 ms; frequency drops to /// 35 rad/s to match the new settle bounce so the two feedback signals /// no longer feel discordant. 250 ms. pub const MOTION_SHAKE_SECS: f32 = 0.25; /// Shake angular frequency in rad/s. pub const MOTION_SHAKE_OMEGA: f32 = 35.0; /// Duration of the smooth return tween when a drag is rejected by an /// invalid drop target. Short enough to feel snappy but long enough to /// read as motion rather than a teleport. pub const MOTION_DRAG_REJECT_SECS: f32 = 0.15; /// Card flip — half-time per phase (squash + grow). 100 ms each = /// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D /// feel without 3D rendering. pub const MOTION_FLIP_HALF_SECS: f32 = 0.10; /// Per-card stagger on the new-game deal animation — centre value; /// each card gets ±10% jitter applied at deal time so the deal feels /// organic instead of mechanical. 40 ms. pub const MOTION_DEAL_STAGGER_SECS: f32 = 0.04; /// Deal slide duration with an `ease-out` curve and a 40 ms /// scale-pop on land so cards "arrive" instead of just stopping. /// 280 ms. pub const MOTION_DEAL_SLIDE_SECS: f32 = 0.28; /// Win cascade per-card stagger — slightly slower than the prior /// 50 ms for a more theatrical feel. 60 ms. pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06; /// Win cascade per-card slide — uses `MotionCurve::Expressive` /// (overshoot) plus ±15° Z-rotation. 500 ms. pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50; /// Per-line stagger between score-breakdown rows during the win modal /// reveal animation, in seconds. pub const MOTION_SCORE_BREAKDOWN_STAGGER_SECS: f32 = 0.15; /// Per-line fade-in duration during the win modal score reveal, in /// seconds. pub const MOTION_SCORE_BREAKDOWN_FADE_SECS: f32 = 0.12; /// Screen shake on win — wider and longer than the old 0.6 s / 8 px. /// 800 ms. pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80; /// Peak displacement of the win screen shake. 12 px. pub const MOTION_WIN_SHAKE_AMPLITUDE: f32 = 12.0; /// Toast in — scale-from-0.92-to-1.0 fade-in. 200 ms. pub const MOTION_TOAST_IN_SECS: f32 = 0.20; /// Toast out — fade. 250 ms. pub const MOTION_TOAST_OUT_SECS: f32 = 0.25; /// Modal in/out — scale-from-0.96-to-1.0 + scrim fade. 220 ms. pub const MOTION_MODAL_SECS: f32 = 0.22; /// Button hover/press colour blend — short, snappy. 100 ms. pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10; /// Score-pulse — when score increases by ≥ 50, briefly scale the /// readout 1.0 → 1.1 → 1.0. 250 ms. pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25; /// Foundation-completion flourish — when a King lands on a foundation /// pile (Ace → King, 13 cards), briefly scale the King card 1.0 → /// [`FOUNDATION_FLOURISH_PEAK_SCALE`] → 1.0 and tint the matching /// `PileMarker` gold. 400 ms. pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4; /// Peak scale magnification reached at the midpoint of the /// foundation-completion flourish. The triangular curve climbs from /// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`. pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15; /// Total duration of the streak-milestone flourish on the HUD score /// readout, in seconds. Mirrors the foundation flourish in feel — a /// brief celebratory pulse that does not block subsequent gameplay. pub const MOTION_STREAK_FLOURISH_SECS: f32 = 0.6; /// Peak scale magnification reached at the midpoint of the streak /// flourish (1.0 → this → 1.0). Larger than the foundation flourish /// peak so the lifetime-streak celebration reads as a bigger deal than /// the per-suit completion. pub const STREAK_FLOURISH_PEAK_SCALE: f32 = 1.20; /// Win-streak counts that trigger the flourish. The flourish fires /// only when the streak crosses a threshold from below — never at /// every win past the highest threshold. Static for now; could become /// a `Settings`-tunable list later if play-testing surfaces it. pub const STREAK_MILESTONES: &[u32] = &[3, 5, 10]; /// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step. /// 400 ms. pub const MOTION_LOADING_TICK_SECS: f32 = 0.40; /// Period of the focus-ring breathing pulse, in seconds. /// /// The keyboard focus ring's alpha is modulated by a sin-curve over this /// interval so the indicator gently "breathes" instead of presenting as /// a flat outline. 1.4 s reads as a calm heartbeat — slow enough that /// the motion is in the player's peripheral vision rather than competing /// for attention, fast enough that a focus change still draws the eye. /// Not run through [`scaled_duration`]: the pulse is an accessibility /// affordance, not gameplay motion. `AnimSpeed::Instant` is honoured at /// the system level by skipping the pulse entirely (see /// `pulse_focus_overlay` in `ui_focus`). pub const MOTION_FOCUS_PULSE_SECS: f32 = 1.4; /// Hover delay before a tooltip appears, in seconds. Long enough that /// players gliding the cursor across the HUD don't see flicker; short /// enough that "stop and read" feels responsive. Not run through /// [`scaled_duration`] — `AnimSpeed` controls gameplay motion, not the /// hover-discoverability budget for help text. pub const MOTION_TOOLTIP_DELAY_SECS: f32 = 0.5; /// Total visible duration of the splash screen overlay, in seconds. /// Composed of a fade-in, a hold, and a fade-out — see /// [`MOTION_SPLASH_FADE_SECS`] for the per-edge fade budget. Not run /// through [`scaled_duration`]: the splash is a one-shot brand beat at /// app start, not gameplay motion that should track `AnimSpeed`. pub const MOTION_SPLASH_TOTAL_SECS: f32 = 1.6; /// Fade-in and fade-out duration of the splash overlay, in seconds. /// The hold time is `MOTION_SPLASH_TOTAL_SECS - 2 * MOTION_SPLASH_FADE_SECS`. /// Mirroring fade-in and fade-out keeps the curve symmetric so the brand /// beat reads as a single dissolve instead of two separate animations. pub const MOTION_SPLASH_FADE_SECS: f32 = 0.3; // --------------------------------------------------------------------------- // Z-index — tooltip layer // --------------------------------------------------------------------------- /// Z-layer for tooltips. Sits one rung above the focus ring so a /// tooltip rendered over a focused button is never occluded by the /// button's outline. Still below `Z_WIN_CASCADE` and `Z_TOAST` so the /// celebration and notification layers stay on top. pub const Z_TOOLTIP: i32 = Z_FOCUS_RING + 10; /// Z-layer for the launch splash overlay. The splash owns the entire /// viewport for ~1.6 s before fading out, so it sits above every other /// UI rung — including `Z_TOAST` — to guarantee the brand beat is /// never occluded by a stray toast or tooltip. Neither toasts nor the /// win cascade can fire during the splash window in practice (no game /// has run yet, no toast queue has dispatched), but the relative order /// is kept tidy in case a future feature schedules either at startup. pub const Z_SPLASH: i32 = Z_TOAST + 100; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Scales a `MOTION_*_SECS` value by the player's animation-speed /// preference. `Normal` × 1.0, `Fast` × 0.5, `Instant` → 0. /// /// Pass any duration constant from this module through this helper /// before handing it to a tween. The audit found that only slide and /// cascade respected `AnimSpeed`; toasts, deal stagger, shake, and /// settle were hardcoded. Routing every duration through this function /// fixes that. pub fn scaled_duration(secs: f32, speed: AnimSpeed) -> f32 { match speed { AnimSpeed::Normal => secs, AnimSpeed::Fast => secs * 0.5, AnimSpeed::Instant => 0.0, } } #[cfg(test)] mod tests { use super::*; /// Every spacing rung is a positive multiple of 4 — keeps the scale /// honest if someone tweaks values later. #[test] fn spacing_scale_is_a_4_multiple_geometric_progression() { for v in [SPACE_1, SPACE_2, SPACE_3, SPACE_4, SPACE_5, SPACE_6, SPACE_7] { assert!(v > 0.0, "spacing tokens must be positive"); assert!( (v.rem_euclid(4.0)).abs() < f32::EPSILON, "spacing token {v} must be a 4-multiple" ); } } /// Type scale is monotonically decreasing display → caption. #[test] fn type_scale_is_monotonically_decreasing() { let scale = [TYPE_DISPLAY, TYPE_HEADLINE, TYPE_BODY_LG, TYPE_BODY, TYPE_CAPTION]; for window in scale.windows(2) { assert!( window[0] > window[1], "type scale must be monotonically decreasing: {} should be > {}", window[0], window[1] ); } } /// Z-index hierarchy is monotonically increasing through documented /// layers, so a future add-a-layer change can't accidentally land /// in the wrong slot. #[test] fn z_index_hierarchy_is_monotonically_increasing() { let layers = [ Z_BACKGROUND, Z_PILE_MARKER, Z_HUD, Z_HUD_POPOVER_BACKDROP, Z_HUD_POPOVER, Z_HUD_TOP, Z_MODAL_SCRIM, Z_MODAL_PANEL, Z_PAUSE, Z_PAUSE_DIALOG, Z_ONBOARDING, Z_FOCUS_RING, Z_TOOLTIP, Z_WIN_CASCADE, Z_TOAST, Z_SPLASH, ]; for window in layers.windows(2) { assert!( window[0] < window[1], "z-index hierarchy must be monotonically increasing: {} should be < {}", window[0], window[1] ); } } #[test] fn scaled_duration_matches_anim_speed() { assert!((scaled_duration(0.18, AnimSpeed::Normal) - 0.18).abs() < f32::EPSILON); assert!((scaled_duration(0.18, AnimSpeed::Fast) - 0.09).abs() < f32::EPSILON); assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0); } }