Compare commits

...

14 Commits

Author SHA1 Message Date
funman300 36605751cd feat(engine): hold-/ hotkey cheat sheet + unified binding table (Phase J)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m33s
Phase J audit findings: the visible focus ring the doc asked for
already exists (FocusOverlay singleton, 2px accent ring, breathing
pulse, reduce-motion aware — landed after the doc was written), and
post-Phase-C every modal already opens and dismisses keyboard-only.
The real gaps were two silently diverging static hotkey tables
(onboarding's slide vs Help's list — onboarding still said 'Mode
Launcher (then 1-5)') and no way to see the bindings mid-game.

- New crate::hotkeys module owns THE binding table (21 rows, verified
  against a grep inventory of every just_pressed(KeyCode::..) handler);
  rows carry an `essential` flag — the onboarding slide teaches that
  subset, the cheat sheet shows everything.
- New cheat_sheet_plugin: hold `/` for a right-anchored reference
  overlay of every binding; release hides it. Deliberately not a
  spawn_modal modal (momentary reference, closer to a tooltip), never
  spawns while a modal owns the screen (also keeps it out of the
  seed-entry field), inert on touch builds.
- Onboarding's stale local table deleted in favour of the shared one
  (copy updated: Home naming, hold-to-repeat undo, added H).

5 new tests (table integrity, essential-subset size, uniqueness,
show/hide driver, modal suppression). Workspace + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:58:23 -07:00
funman300 4d9a07727a Merge pull request 'feat(engine): UI scale setting — 90/100/115/130% (Phase K)' (#182) from feat/ui-scale into master
Test / fmt (push) Successful in 5s
Test / test (push) Successful in 4m46s
Build and Deploy / build-and-push (push) Failing after 6m38s
Web E2E / web-e2e (push) Successful in 8m23s
2026-07-14 01:45:01 +00:00
funman300 513dee8fa9 Merge pull request 'feat(engine): contextual one-time tips — stall hint + radial teach (Phase I complete)' (#181) from feat/contextual-tips into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m32s
Build and Deploy / build-and-push (push) Failing after 6m58s
Web E2E / web-e2e (push) Successful in 8m49s
2026-07-14 01:43:51 +00:00
funman300 19ddf86c7b Merge pull request 'feat(engine): one-shot What's-new card after updates (Phase I)' (#180) from feat/whats-new-card into master
Test / fmt (push) Successful in 5s
Test / test (push) Successful in 4m23s
Build and Deploy / build-and-push (push) Failing after 6m36s
Web E2E / web-e2e (push) Successful in 8m43s
2026-07-14 01:43:47 +00:00
funman300 28be65f092 feat(engine): UI scale setting — 90/100/115/130% (Phase K)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m22s
Settings -> Accessibility gains a UI Scale row cycling four steps,
applied live through bevy::ui::UiScale: every menu, modal, and HUD
element scales while the table itself stays window-fit via
compute_layout. New Settings::ui_scale (serde default 1.0, sanitized
clamp to [0.9, 1.3]).

Safe-area anchors and modal scrim padding pre-divide physical insets
by the UI scale so post-multiplication lands exactly on the system
bars — without this, 90% would sink the bottom action bar into the
Android gesture zone. The anchor systems also re-run on UiScale
changes, not just inset changes.

Deferred from Phase K (noted in the doc): seeding the setting from the
Android system font scale on first run (needs JNI), and the 44px
touch-target audit (on-device).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:39:19 -07:00
funman300 65913de2cb feat(engine): contextual one-time tips — stall hint, radial menu teach (Phase I)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m15s
Two situation-fired teaches, each shown exactly once and recorded in
Settings like shown_achievement_onboarding:

- Stall tip: 45s with no board change in an active, started game
  (clock frozen while paused / a modal is open) points at Hint with
  platform-correct copy (H on desktop, bottom-bar Hint on touch).
- Radial teach: a player 15 moves into a game who has never opened the
  radial menu learns the long-press / right-click gesture. Organic
  radial use marks the tip done silently — nobody is taught what they
  already know.

Tips ride the queued InfoToastEvent path, so they render in the unified
toast stack and never interrupt play. This completes Phase I.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:16:56 -07:00
funman300 25f1fd27d9 feat(engine): one-shot What's-new card after updates (Phase I)
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 5m0s
ObtainX updates install silently, so shipped features went unnoticed.
On the first launch where the running release differs from the new
Settings::last_seen_whats_new, a dismissible card summarises the
latest CHANGELOG.md section (embedded; its top version doubles as the
app's release identity — no build-time version plumbing). Internal
sections are dropped and bullets reduce to their bold lead sentence.

Launch beat: splash -> onboarding (first run) -> what's-new -> Home;
spawn_home_on_launch waits on the new WhatsNewPending resource. Fresh
installs never see the card — onboarding completion stamps the current
version silently. The seen-stamp persists on spawn, not dismissal, so
the card can never nag twice.

8 new tests (changelog parsing incl. the real embedded file, upgrade/
seen/fresh-install gating, dismissal). Workspace + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:05:15 -07:00
funman300 9a0d6496c5 docs(changelog): cut 0.46.0 — Phase H feedback polish
Android Release / build-apk (push) Successful in 5m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:50:29 -07:00
funman300 7669a1bb56 Merge pull request 'feat(engine): theme-store preview thumbnails (Phase H part 2)' (#179) from feat/theme-store-previews into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m41s
Build and Deploy / build-and-push (push) Successful in 8m56s
Web E2E / web-e2e (push) Successful in 7m33s
2026-07-14 00:46:46 +00:00
funman300 ddee605874 feat(engine): hint ghost-motion preview (Phase H)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m20s
A hint now spawns a translucent copy of the hinted card that glides to
the suggested destination twice (0.7s per pass, SmoothSnap easing, tail
fade so the loop reads as a repeat), alongside the existing static
source/destination highlights. Auto-disabled under reduce-motion — the
static highlights remain the whole story. The ghost despawns on timer,
on a fresh hint, or the moment the board changes (a preview of a stale
board is worse than none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:53 -07:00
funman300 ae8118d7df feat(engine): theme-store preview thumbnails (Phase H part 2)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m36s
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).

solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:26:47 -07:00
funman300 739c94d3bb Merge pull request 'polish(engine): Phase H part 1 — unified toast stack, onboarding fixes' (#178) from feat/feedback-polish into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m24s
Build and Deploy / build-and-push (push) Successful in 9m8s
Web E2E / web-e2e (push) Successful in 8m15s
2026-07-14 00:14:03 +00:00
funman300 3388169329 fix(engine): platform-correct onboarding copy; Home waits for first-run onboarding (Phase H)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m29s
- The how-to-play slide told touch players to left/right-click; Android
  now gets tap/double-tap copy pointing at the bottom-bar Hint button.
- On a fresh profile the Home auto-show spawned underneath the
  onboarding modal, stacking two scrims (v0.44.0 emulator smoke
  finding). spawn_home_on_launch now waits until first_run_complete
  and the onboarding modal is gone, so the launch beat is onboarding,
  then Home, then the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:52:16 -07:00
funman300 0e07e1d1ad refactor(engine): unify toasts into one bottom-anchored stack (Phase H)
Queued and immediate toasts used two subtly different geometries and
staggered anchors purely to dodge each other when simultaneous. Both
paths now spawn geometry-free ToastNodes that adopt_toasts_into_stack
slots into a single persistent ToastStackRoot flex column — one anchor,
one style, simultaneous toasts stack upward. The root rides
SafeAreaAnchoredBottom and clears the Phase F touch action bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:48:46 -07:00
20 changed files with 2271 additions and 163 deletions
+30
View File
@@ -6,6 +6,36 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.46.0] — 2026-07-13
### Added
- **Theme-store previews.** The store modal now shows each theme's
preview image next to its name — the server has advertised them since
the store launched; the client finally fetches them. Thumbnails load
in the background, pop in as they arrive, and are remembered for the
session. (#179)
- **Hint ghost preview.** Asking for a hint now also plays a translucent
copy of the suggested card gliding to its destination (twice, then it
fades) alongside the usual highlights — you see the move, not just the
pieces. Automatically disabled when reduce-motion is on. (#179)
### Changed
- **One toast style, one place.** Queued info banners and instant
celebration/warning/error toasts now share a single bottom-anchored
stack that clears the touch action bar; simultaneous toasts stack
upward instead of overlapping. (#178)
### Fixed
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
players to left/right-click; it now describes drag, double-tap, and
the bottom-bar Hint button. (#178)
- **First launch shows one screen at a time.** On a fresh install the
Home screen no longer spawns underneath the welcome tour; the order is
now onboarding → Home → table. (#178)
## [0.45.0] — 2026-07-13 ## [0.45.0] — 2026-07-13
### Added ### Added
+41
View File
@@ -257,6 +257,32 @@ pub struct Settings {
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`. /// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub last_mode: GameMode, pub last_mode: GameMode,
/// The release version whose "What's new" card the player has already
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
/// which correctly reads as "there is news to show" after an upgrade;
/// fresh installs stamp it silently when onboarding completes. Older
/// `settings.json` files deserialize cleanly to `""` via
/// `#[serde(default)]`.
#[serde(default)]
pub last_seen_whats_new: String,
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
/// fired (or been suppressed as unnecessary). Phase I teach: fired
/// by the situation, shown once, like `shown_achievement_onboarding`.
#[serde(default)]
pub shown_stall_hint_tip: bool,
/// `true` once the one-shot radial-menu contextual tip has fired —
/// or the player has already opened the radial menu on their own,
/// which marks the tip as unnecessary without showing it.
#[serde(default)]
pub shown_radial_menu_tip: bool,
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
/// action bar) — the table itself stays window-fit via
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
/// files deserialize cleanly to `1.0` via the serde default.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
/// Custom public name displayed on the leaderboard. When `None`, the /// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters /// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this /// before submission. Older `settings.json` files written before this
@@ -365,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
/// Lowest / highest UI scale the settings row offers; anything outside
/// (hand-edited settings.json) clamps here on load.
pub const UI_SCALE_MIN: f32 = 0.9;
/// See [`UI_SCALE_MIN`].
pub const UI_SCALE_MAX: f32 = 1.3;
fn default_ui_scale() -> f32 {
1.0
}
fn default_matomo_site_id() -> u32 { fn default_matomo_site_id() -> u32 {
1 1
} }
@@ -425,6 +461,10 @@ impl Default for Settings {
replay_move_interval_secs: default_replay_move_interval_secs(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None, last_difficulty: None,
last_mode: GameMode::Classic, last_mode: GameMode::Classic,
last_seen_whats_new: String::new(),
shown_stall_hint_tip: false,
shown_radial_menu_tip: false,
ui_scale: default_ui_scale(),
leaderboard_display_name: None, leaderboard_display_name: None,
leaderboard_opted_in: false, leaderboard_opted_in: false,
take_from_foundation: true, take_from_foundation: true,
@@ -460,6 +500,7 @@ impl Settings {
replay_move_interval_secs: self replay_move_interval_secs: self
.replay_move_interval_secs .replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS), .clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
selected_theme_id, selected_theme_id,
..self ..self
} }
+40
View File
@@ -19,6 +19,10 @@ use thiserror::Error;
/// so downloading it is pure waste. /// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Hard cap on a preview PNG download — previews are small decorative
/// thumbnails; anything past this is a misconfigured server.
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
/// Errors surfaced by [`ThemeStoreClient`]. /// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ThemeStoreError { pub enum ThemeStoreError {
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
verify_archive(&bytes, entry)?; verify_archive(&bytes, entry)?;
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
/// Fetch the preview PNG the catalog advertises for `entry`.
///
/// Returns `Http(404)` when the entry carries no `preview_url` —
/// the same shape the server answers with when the file is absent,
/// so callers only handle one "no preview" case. Previews are
/// decorative; unlike archives they carry no checksum, only the
/// [`MAX_PREVIEW_BYTES`] size cap.
pub async fn fetch_preview(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
let Some(path) = entry.preview_url.as_deref() else {
return Err(ThemeStoreError::Http(404));
};
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_PREVIEW_BYTES,
got: bytes.len() as u64,
});
}
Ok(bytes.to_vec())
}
} }
/// Checks downloaded `bytes` against the catalog `entry`'s declared /// Checks downloaded `bytes` against the catalog `entry`'s declared
+158 -37
View File
@@ -29,7 +29,9 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent; use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{ use crate::ui_theme::{
@@ -160,6 +162,68 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds. /// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5; const QUEUED_TOAST_SECS: f32 = 2.5;
/// Marker on the persistent bottom-anchored flex column every toast
/// spawns into (Phase H). Stacking through one container gives queued
/// and immediate toasts a single shared anchor — simultaneous toasts
/// stack upward instead of relying on the old staggered-percentage
/// anchors to dodge each other.
#[derive(Component, Debug)]
pub struct ToastStackRoot;
/// Marker on every toast card node (both paths). Freshly spawned toasts
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
/// of latency, imperceptible at toast timescales, in exchange for the 14
/// toast handlers keeping their `Commands`-only signatures.
#[derive(Component, Debug)]
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
/// before safe-area insets. Clears the Phase F bottom action bar on
/// touch (compact 44px buttons + primary 64px trio + bar padding);
/// desktop's shorter bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
commands.spawn((
ToastStackRoot,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
left: Val::Px(0.0),
width: Val::Percent(100.0),
// Newest toast sits nearest the bottom edge; older ones
// push upward.
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
SafeAreaAnchoredBottom {
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
},
ZIndex(Z_TOAST),
));
}
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
/// reveals them. No-op when every toast is already adopted.
fn adopt_toasts_into_stack(
mut commands: Commands,
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
let Ok(root) = root.single() else {
return;
};
for toast in &orphans {
commands
.entity(toast)
.insert((ChildOf(root), Visibility::Inherited));
}
}
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin; pub struct AnimationPlugin;
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
.add_systems(Startup, init_slide_duration) .add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.add_systems( .add_systems(
Update, Update,
( (
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
handle_warning_toast, handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
adopt_toasts_into_stack,
) )
.after(GameMutation), .after(GameMutation),
); );
@@ -637,26 +702,14 @@ impl ToastVariant {
} }
} }
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system. /// Spawns a `ToastEntity` for the queued toast system.
/// ///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed /// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info. /// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path /// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent. /// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity { fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node( spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
commands,
ToastEntity,
message,
ToastVariant::Info,
// Slightly taller anchor than the immediate-fire path so a
// queued info banner doesn't collide with a celebration toast
// fired in the same frame.
Val::Percent(6.0),
Val::Percent(15.0),
Val::Percent(70.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
)
} }
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) { fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
@@ -744,12 +797,6 @@ fn spawn_toast(
(ToastOverlay, ToastTimer(duration_secs)), (ToastOverlay, ToastTimer(duration_secs)),
message, message,
variant, variant,
// Sits above the queued banner so a celebration toast spawned
// alongside a queued info message remains readable.
Val::Percent(14.0),
Val::Percent(25.0),
Val::Percent(50.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
); );
} }
@@ -766,31 +813,25 @@ fn spawn_toast(
/// rungs; 18 is the closest rung that preserves the scale invariants /// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`. /// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners. /// - [`RADIUS_MD`] corners.
/// - Bottom-anchored absolute position; `bottom_pct` differs between ///
/// queued and immediate paths so they layer instead of overlap. /// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
// The 8-argument signature is intentional — these are the per-toast /// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
// layout values that genuinely differ between the queued and fire-and- /// into the shared bottom-anchored column — one anchor for every toast,
// forget call sites. A struct wrapper would just rename the same data. /// simultaneous toasts stack instead of overlapping.
#[allow(clippy::too_many_arguments)]
fn spawn_toast_node<B: Bundle>( fn spawn_toast_node<B: Bundle>(
commands: &mut Commands, commands: &mut Commands,
bundle: B, bundle: B,
message: String, message: String,
variant: ToastVariant, variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((
bundle, bundle,
ToastNode,
Visibility::Hidden,
Node { Node {
position_type: PositionType::Absolute, max_width: Val::Percent(70.0),
left: left_pct, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
bottom: bottom_pct,
width: width_pct,
padding,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()), BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -1315,4 +1355,85 @@ mod tests {
fn cascade_duration_instant_is_zero() { fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0); assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
} }
// -----------------------------------------------------------------------
// Phase H: unified toast stack
// -----------------------------------------------------------------------
/// Both toast paths must end up as visible children of the single
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
#[test]
fn queued_and_immediate_toasts_stack_under_one_root() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update(); // Startup: spawns the stack root.
// One immediate (error) toast + one queued (info) toast in the
// same frame — the exact collision case the old staggered
// anchors existed to dodge.
use solitaire_core::{KlondikePile, Tableau};
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.world_mut()
.write_message(InfoToastEvent("stacked info".to_string()));
app.update(); // handlers spawn both toasts (hidden, unparented)
app.update(); // adopt_toasts_into_stack re-parents + reveals
let root = app
.world_mut()
.query_filtered::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.iter(app.world())
.collect();
assert_eq!(
toasts.len(),
2,
"both the immediate and the queued toast must be adopted"
);
for (entity, child_of, visibility) in toasts {
assert_eq!(
child_of.parent(),
root,
"toast {entity} must be a child of the shared stack root"
);
assert_eq!(
*visibility,
Visibility::Inherited,
"adopted toast {entity} must be revealed"
);
}
}
/// Toast nodes must not carry their own absolute positioning — the
/// stack root owns layout (regression guard against reintroducing
/// per-path anchors).
#[test]
fn toast_nodes_have_no_absolute_position() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app.world_mut()
.write_message(WarningToastEvent("layout check".to_string()));
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ToastNode>>()
.single(app.world())
.expect("warning toast must spawn a ToastNode");
assert_eq!(
node.position_type,
PositionType::Relative,
"toast nodes are flex children of the stack, not absolute overlays"
);
}
} }
+194
View File
@@ -0,0 +1,194 @@
//! Hold-`/` hotkey cheat sheet (Phase J).
//!
//! While `/` is held on a keyboard platform, a lightweight overlay
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
//! table the onboarding slide teaches from, so the two can never
//! disagree. Releasing the key hides it instantly; it never captures
//! input, never pauses the game, and never spawns while a modal owns
//! the screen (which also keeps it out of the seed-entry text field's
//! way).
//!
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
//! this is a momentary reference card, closer to a tooltip than a
//! dialog.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::hotkeys::HOTKEYS;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_TOOLTIP,
};
/// Marker on the cheat-sheet overlay root.
#[derive(Component, Debug)]
pub struct CheatSheetOverlay;
/// Registers the hold-`/` driver. Inert on touch-first builds.
pub struct CheatSheetPlugin;
impl Plugin for CheatSheetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ButtonInput<KeyCode>>()
.add_systems(Update, drive_cheat_sheet);
}
}
/// Shows the overlay while `/` is held (and no modal owns the screen);
/// hides it the frame the key releases.
fn drive_cheat_sheet(
keys: Res<ButtonInput<KeyCode>>,
scrims: Query<(), With<ModalScrim>>,
existing: Query<Entity, With<CheatSheetOverlay>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
if !SHOW_KEYBOARD_ACCELERATORS {
return;
}
let held = keys.pressed(KeyCode::Slash);
if held && existing.is_empty() && scrims.is_empty() {
spawn_cheat_sheet(&mut commands, font_res.as_deref());
} else if !held {
for entity in &existing {
commands.entity(entity).despawn();
}
}
}
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_header = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_keys = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_desc = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
commands
.spawn((
CheatSheetOverlay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(16.0),
top: Val::Percent(8.0),
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_4),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
max_height: Val::Percent(84.0),
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG),
HighContrastBorder::with_default(BORDER_STRONG),
GlobalZIndex(Z_TOOLTIP),
))
.with_children(|panel| {
panel.spawn((
Text::new("Keyboard shortcuts"),
font_header.clone(),
TextColor(TEXT_PRIMARY),
Node {
margin: UiRect::bottom(VAL_SPACE_2),
..default()
},
));
for row in HOTKEYS {
panel
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
line.spawn((
Text::new(row.keys),
font_keys.clone(),
TextColor(ACCENT_PRIMARY),
Node {
min_width: Val::Px(110.0),
..default()
},
));
line.spawn((
Text::new(row.description),
font_desc.clone(),
TextColor(TEXT_SECONDARY),
));
});
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CheatSheetPlugin);
app.update();
app
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&CheatSheetOverlay>()
.iter(app.world())
.count()
}
#[test]
fn holding_slash_shows_and_release_hides() {
let mut app = app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
// Still held on later frames: exactly one overlay, no stacking.
app.update();
assert_eq!(overlay_count(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::Slash);
app.update();
app.update();
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
}
#[test]
fn suppressed_while_a_modal_is_open() {
let mut app = app();
app.world_mut().spawn(ModalScrim);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"the sheet must not spawn over a modal (or into a text field)"
);
}
}
@@ -0,0 +1,309 @@
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
//!
//! Everything the first-run onboarding doesn't cover is invisible until
//! stumbled upon: hint cycling, the radial quick-action menu. These
//! tips fire **from the situation, not a tour** — each exactly once,
//! recorded in `Settings` like `shown_achievement_onboarding`:
//!
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
//! active game, an info toast points at Hint. A player staring at a
//! stuck board is the one moment the tip is welcome.
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
//! (an engaged player) and the radial menu has never been opened, an
//! info toast teaches the long-press / right-click gesture. Opening
//! the radial organically marks the tip as unnecessary — it is never
//! shown to someone who already knows.
//!
//! Tips render through the queued [`InfoToastEvent`] path, so they share
//! the unified toast stack and never interrupt play.
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::events::{InfoToastEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::radial_menu::RightClickRadialState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::ModalScrim;
/// Seconds without any board change before the stall tip fires.
/// Long enough that normal thinking never triggers it; short enough to
/// reach a genuinely stuck player before they quit.
const STALL_TIP_SECS: f32 = 45.0;
/// Move count at which an engaged player earns the radial-menu teach.
const RADIAL_TIP_MIN_MOVES: u32 = 15;
/// Stall-tip copy per platform input vocabulary.
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Stuck? Press H for a hint."
} else {
"Stuck? Tap Hint in the bottom bar for a suggested move."
};
/// Radial-tip copy per platform input vocabulary.
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Tip: right-click a card for quick actions."
} else {
"Tip: long-press a card for quick actions."
};
/// Seconds of board inactivity, frozen while paused / a modal is open,
/// reset by every [`StateChangedEvent`].
#[derive(Resource, Debug, Default)]
pub struct StallClock(pub f32);
/// Registers the stall clock and the two tip triggers.
pub struct ContextualTipsPlugin;
impl Plugin for ContextualTipsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StallClock>()
.add_message::<InfoToastEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
tick_stall_clock,
fire_stall_hint_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
observe_radial_menu_use,
fire_radial_menu_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain()
.after(GameMutation),
);
}
}
/// Advances the stall clock; resets on any board change; freezes while
/// paused, while a modal owns the screen, or when the game is over.
fn tick_stall_clock(
time: Res<Time>,
mut state_events: MessageReader<StateChangedEvent>,
paused: Option<Res<PausedResource>>,
scrims: Query<(), With<ModalScrim>>,
game: Option<Res<GameStateResource>>,
mut clock: ResMut<StallClock>,
) {
if state_events.read().next().is_some() {
clock.0 = 0.0;
return;
}
if paused.is_some_and(|p| p.0)
|| !scrims.is_empty()
|| game.as_ref().is_none_or(|g| g.0.is_won())
{
// Frozen, not reset: backgrounding into a menu mid-stall
// shouldn't restart the wait.
return;
}
clock.0 += time.delta_secs();
}
/// Fires the one-shot stall tip once the clock passes the threshold in
/// a game the player has actually started (at least one move).
fn fire_stall_hint_tip(
clock: Res<StallClock>,
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(STALL_TIP.to_string()));
s.0.shown_stall_hint_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// A player who opens the radial menu on their own doesn't need the
/// teach — mark the tip done silently.
fn observe_radial_menu_use(
radial: Option<Res<RightClickRadialState>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
) {
let Some(radial) = radial else { return };
if !radial.is_active() {
return;
}
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// Fires the one-shot radial teach for an engaged player who has never
/// opened the menu themselves.
fn fire_radial_menu_tip(
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, settings)
{
warn!("contextual tips: failed to persist tip flag: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
fn app_with(settings: Settings, game: GameState) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ContextualTipsPlugin);
app.insert_resource(SettingsResource(settings));
app.insert_resource(GameStateResource(game));
app.update();
app
}
fn info_toast_count(app: &App) -> usize {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).count()
}
fn started_game() -> GameState {
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
game.draw().expect("draw from fresh deal");
game
}
#[test]
fn stall_tip_fires_once_past_threshold() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_stall_hint_tip,
"the tip flag must set so it never repeats"
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
app.world_mut()
.resource_mut::<Messages<InfoToastEvent>>()
.clear();
app.update();
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
}
#[test]
fn stall_tip_needs_a_started_unwon_game() {
// Untouched deal: staring at the launch screen is not a stall.
let mut app = app_with(
Settings::default(),
GameState::new(7, DrawStockConfig::DrawOne),
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
// Won game: nothing to hint at.
let mut won = started_game();
won.set_test_won(true);
let mut app = app_with(Settings::default(), won);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
}
#[test]
fn stall_clock_resets_on_state_change() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
app.world_mut().write_message(StateChangedEvent);
app.update();
assert_eq!(
app.world().resource::<StallClock>().0,
0.0,
"any board change must reset the stall clock"
);
}
#[test]
fn radial_tip_fires_for_engaged_player() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(Settings::default(), game);
app.update();
assert_eq!(
info_toast_count(&app),
1,
"radial tip must fire at the move threshold"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_radial_menu_tip
);
}
#[test]
fn radial_tip_suppressed_after_organic_use() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(
Settings {
shown_radial_menu_tip: true,
..Settings::default()
},
game,
);
app.update();
assert_eq!(
info_toast_count(&app),
0,
"a player who used the radial menu never sees the teach"
);
}
}
+11 -8
View File
@@ -14,14 +14,14 @@ use crate::platform::{
}; };
use crate::{ use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
WinSummaryPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
@@ -115,6 +115,9 @@ impl Plugin for CoreGamePlugin {
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin)
.add_plugins(CheatSheetPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
+304 -3
View File
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
use crate::card_plugin::CardEntity; use crate::card_animation::{MotionCurve, sample_curve};
use crate::card_plugin::{CardEntity, CardEntityIndex};
use crate::events::{ use crate::events::{
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent, DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
NewGameRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>() .add_message::<RequestRedraw>()
.add_systems( .add_systems(
Update, Update,
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
start_deal_anim.after(GameMutation), start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation), start_foundation_flourish.after(GameMutation),
), ),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
// ghost entities (Without<CardEntity>) and stays conflict-free.
.add_systems(
Update,
(
spawn_hint_ghost
.after(GameMutation)
.after(crate::card_plugin::BoardVisuals),
tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation),
)
.chain(),
); );
} }
} }
@@ -664,6 +681,147 @@ fn pile_cards(
} }
} }
// ---------------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// ---------------------------------------------------------------------------
/// Duration of one ghost glide from the hinted card to its destination.
const HINT_GHOST_PASS_SECS: f32 = 0.7;
/// How many glides one hint plays before the ghost despawns. Two reads
/// as "this move, over there" without outstaying the 2 s static
/// highlight it accompanies.
const HINT_GHOST_PASSES: f32 = 2.0;
/// Ghost translucency — clearly a projection, never mistakable for the
/// real card.
const HINT_GHOST_ALPHA: f32 = 0.45;
/// Ghost render depth: above every settled pile (~1.04 max) and the
/// in-flight `CardAnim` lift (50), below a dragged card (500).
const HINT_GHOST_Z: f32 = 400.0;
/// A translucent copy of the hinted card gliding to the suggested
/// destination (Phase H). Purely decorative — despawned by timer, by a
/// newer hint, or by any state change.
#[derive(Component, Debug)]
pub struct HintGhost {
start: Vec3,
target: Vec3,
elapsed: f32,
}
/// Normalised progress of the current glide pass, restarting from the
/// source each pass. Pure for unit testing.
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
}
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
/// fading over the last 20 % so the loop restart reads as a repeat
/// rather than a teleport. Pure for unit testing.
fn hint_ghost_alpha(pass_t: f32) -> f32 {
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
}
/// Spawns the ghost when a hint fires. The static highlights (source
/// card + gold destination pile) still spawn regardless; under
/// reduce-motion they are the whole story and no ghost appears
/// (`design-system.md` §Accessibility).
#[allow(clippy::too_many_arguments)]
fn spawn_hint_ghost(
mut events: MessageReader<HintVisualEvent>,
settings: Option<Res<SettingsResource>>,
index: Option<Res<CardEntityIndex>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
existing: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
events.clear();
return;
}
let (Some(index), Some(layout)) = (index, layout) else {
events.clear();
return;
};
for ev in events.read() {
// A fresh hint replaces any ghost still in flight.
for entity in &existing {
commands.entity(entity).despawn();
}
let Some(card_entity) = index.get(&ev.source_card) else {
continue;
};
let Ok((transform, sprite)) = cards.get(card_entity) else {
continue;
};
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
continue;
};
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
let mut ghost_sprite = sprite.clone();
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
commands.spawn((
HintGhost {
start,
target: dest.extend(HINT_GHOST_Z),
elapsed: 0.0,
},
ghost_sprite,
Transform::from_translation(start),
));
}
}
/// Advances every ghost: eased glide per pass, tail fade, despawn after
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
/// decorative animation.
#[allow(clippy::type_complexity)]
fn tick_hint_ghosts(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut ghosts: Query<
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
(Without<CardEntity>, Without<PileMarker>),
>,
mut commands: Commands,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
ghost.elapsed += dt;
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
commands.entity(entity).despawn();
continue;
}
let pass_t = hint_ghost_pass_t(ghost.elapsed);
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
transform.translation = ghost.start.lerp(ghost.target, eased);
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
}
}
/// A ghost previews a move against the *current* board; the moment the
/// board changes (move, undo, draw, new game) it is stale and vanishes.
fn despawn_hint_ghosts_on_state_change(
mut events: MessageReader<StateChangedEvent>,
ghosts: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
events.clear();
for entity in &ghosts {
commands.entity(entity).despawn();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -955,4 +1113,147 @@ mod tests {
"FoundationFlourish must not be inserted under reduce-motion" "FoundationFlourish must not be inserted under reduce-motion"
); );
} }
// -----------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// -----------------------------------------------------------------------
#[test]
fn hint_ghost_pass_t_restarts_each_pass() {
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
// Just past one full pass, progress wraps back near zero.
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
}
#[test]
fn hint_ghost_alpha_fades_only_in_the_tail() {
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
}
/// App with one indexed card entity and a real layout, ready to
/// receive `HintVisualEvent`s.
fn ghost_app(reduce_motion: bool) -> (App, Card) {
use solitaire_core::{Deck, Rank, Suit};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
// Sibling systems in the plugin take GameStateResource
// non-optionally; give them a fresh deal.
app.insert_resource(GameStateResource(
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
));
app.insert_resource(SettingsResource(solitaire_data::Settings {
reduce_motion_mode: reduce_motion,
..Default::default()
}));
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
let entity = app
.world_mut()
.spawn((
CardEntity { card: card.clone() },
Sprite::default(),
Transform::from_xyz(-100.0, 40.0, 1.0),
))
.id();
let mut index = CardEntityIndex::default();
index.0.insert(card.clone(), entity);
app.insert_resource(index);
app.insert_resource(LayoutResource(crate::layout::compute_layout(
Vec2::new(1280.0, 720.0),
0.0,
0.0,
true,
)));
app.update();
(app, card)
}
fn fire_hint(app: &mut App, card: Card) {
use solitaire_core::Tableau;
app.world_mut().write_message(HintVisualEvent {
source_card: card,
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
});
app.update();
}
fn ghost_count(app: &mut App) -> usize {
app.world_mut()
.query::<&HintGhost>()
.iter(app.world())
.count()
}
#[test]
fn hint_event_spawns_one_ghost_at_the_source_card() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
let transform = app
.world_mut()
.query_filtered::<&Transform, With<HintGhost>>()
.single(app.world())
.expect("ghost transform");
// The tick system may have advanced the ghost by one real-time
// frame already; assert it is still essentially at the source.
assert!(
transform
.translation
.truncate()
.distance(Vec2::new(-100.0, 40.0))
< 5.0,
"ghost must start at the hinted card, got {:?}",
transform.translation
);
assert_eq!(transform.translation.z, HINT_GHOST_Z);
}
#[test]
fn hint_ghost_suppressed_under_reduce_motion() {
let (mut app, card) = ghost_app(true);
fire_hint(&mut app, card);
assert_eq!(
ghost_count(&mut app),
0,
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
);
}
#[test]
fn state_change_despawns_the_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1);
app.world_mut().write_message(StateChangedEvent);
app.update();
app.update();
assert_eq!(
ghost_count(&mut app),
0,
"a board change makes the previewed move stale — the ghost must vanish"
);
}
#[test]
fn second_hint_replaces_the_first_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card.clone());
fire_hint(&mut app, card);
app.update();
assert_eq!(
ghost_count(&mut app),
1,
"re-hinting must replace the in-flight ghost, not stack a second"
);
}
} }
+17
View File
@@ -475,6 +475,15 @@ fn persist_last_mode(
/// must be empty — when the player has a saved in-progress game the /// must be empty — when the player has a saved in-progress game the
/// restore prompt takes precedence; the home picker would compete /// restore prompt takes precedence; the home picker would compete
/// with it for attention. /// with it for attention.
/// * First-run onboarding must be finished (`Settings.first_run_complete`
/// and no `OnboardingScreen` open) — on a fresh profile the onboarding
/// owns the launch beat; Home appearing underneath it stacked two
/// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home
/// spawns on the first frame after the player finishes or skips.
/// * The What's-new card (Phase I) must have had its beat —
/// [`crate::whats_new_plugin::WhatsNewPending`] released — so an
/// upgrade's release notes get read before the mode picker lands on
/// top of them.
/// * `HomeScreen` must not already exist (defensive — e.g. the player /// * `HomeScreen` must not already exist (defensive — e.g. the player
/// pressed `M` between ticks). /// pressed `M` between ticks).
/// * `LaunchHomeShown` flips to `true` after the first spawn so this /// * `LaunchHomeShown` flips to `true` after the first spawn so this
@@ -488,6 +497,8 @@ fn spawn_home_on_launch(
splash: Query<(), With<crate::splash_plugin::SplashRoot>>, splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>, restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>, pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
whats_new: Option<Res<crate::whats_new_plugin::WhatsNewPending>>,
existing: Query<(), With<HomeScreen>>, existing: Query<(), With<HomeScreen>>,
sources: HomeSpawnSources, sources: HomeSpawnSources,
mut deal_expanded: ResMut<DealOptionsExpanded>, mut deal_expanded: ResMut<DealOptionsExpanded>,
@@ -496,6 +507,12 @@ fn spawn_home_on_launch(
|| !splash.is_empty() || !splash.is_empty()
|| !restore_prompts.is_empty() || !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some()) || pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|| !onboarding.is_empty()
|| whats_new.as_ref().is_some_and(|w| w.0)
|| sources
.settings
.as_ref()
.is_some_and(|s| !s.0.first_run_complete)
|| !existing.is_empty() || !existing.is_empty()
{ {
return; return;
+169
View File
@@ -0,0 +1,169 @@
//! Single source of truth for the desktop keyboard bindings (Phase J).
//!
//! Two static hotkey tables had already diverged (onboarding's slide
//! and Help's controls reference); every future drift multiplies. This
//! module owns THE table: the onboarding slide renders the
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
//! ([`crate::cheat_sheet_plugin`]) renders everything.
//!
//! The table is hand-maintained but **pinned by test** against the
//! handlers that actually consume each key — adding a binding without
//! updating this table (or vice versa) is designed to fail review, not
//! runtime. A registry generated from the input systems themselves is
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
/// One row of the hotkey table.
#[derive(Debug, Clone, Copy)]
pub struct HotkeyRow {
/// Display form of the key(s), e.g. `"D / Space"`.
pub keys: &'static str,
/// One-line action description.
pub description: &'static str,
/// `true` for the beginner-relevant subset the onboarding slide
/// shows; the cheat sheet always shows every row.
pub essential: bool,
}
/// Every desktop keyboard binding, in teaching order.
pub const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
essential: true,
},
HotkeyRow {
keys: "U",
description: "Undo last move (hold to repeat)",
essential: true,
},
HotkeyRow {
keys: "H",
description: "Hint (repeat to cycle alternatives)",
essential: true,
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
essential: true,
},
HotkeyRow {
keys: "N",
description: "New Classic game",
essential: true,
},
HotkeyRow {
keys: "M",
description: "Open Home (then 16 to pick a mode)",
essential: true,
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume; close the top dialog",
essential: true,
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
essential: true,
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
essential: true,
},
HotkeyRow {
keys: "A",
description: "Achievements",
essential: true,
},
HotkeyRow {
keys: "O",
description: "Settings",
essential: true,
},
HotkeyRow {
keys: "P",
description: "Profile",
essential: false,
},
HotkeyRow {
keys: "L",
description: "Leaderboard",
essential: false,
},
HotkeyRow {
keys: "C",
description: "Daily Challenge",
essential: false,
},
HotkeyRow {
keys: "Z",
description: "Zen mode",
essential: false,
},
HotkeyRow {
keys: "X",
description: "Challenge mode",
essential: false,
},
HotkeyRow {
keys: "T",
description: "Time Attack",
essential: false,
},
HotkeyRow {
keys: "G",
description: "Give up the current deal",
essential: false,
},
HotkeyRow {
keys: "[ / ]",
description: "Volume down / up",
essential: false,
},
HotkeyRow {
keys: "F11",
description: "Toggle fullscreen",
essential: false,
},
HotkeyRow {
keys: "/ (hold)",
description: "This cheat sheet",
essential: false,
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_populated_and_well_formed() {
assert!(!HOTKEYS.is_empty());
for row in HOTKEYS {
assert!(!row.keys.trim().is_empty(), "empty keys cell");
assert!(
!row.description.trim().is_empty(),
"empty description for {}",
row.keys
);
}
}
#[test]
fn essential_subset_is_a_teachable_size() {
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
assert!(
(6..=12).contains(&essentials),
"the onboarding slide wants a skimmable essential set, got {essentials}"
);
}
#[test]
fn keys_cells_are_unique() {
let mut seen = std::collections::HashSet::new();
for row in HOTKEYS {
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
}
}
}
+7
View File
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cheat_sheet_plugin;
pub mod contextual_tips_plugin;
pub mod core_game_plugin; pub mod core_game_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
pub mod daily_challenge_plugin; pub mod daily_challenge_plugin;
@@ -26,6 +28,7 @@ pub mod font_plugin;
pub mod game_plugin; pub mod game_plugin;
pub mod help_plugin; pub mod help_plugin;
pub mod home_plugin; pub mod home_plugin;
pub mod hotkeys;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
@@ -64,6 +67,7 @@ pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
pub mod ui_tooltip; pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub mod you_hub_plugin; pub mod you_hub_plugin;
@@ -93,6 +97,8 @@ pub use card_plugin::{
pub use challenge_plugin::{ pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label, CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
}; };
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
pub use contextual_tips_plugin::ContextualTipsPlugin;
pub use core_game_plugin::CoreGamePlugin; pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{ pub use daily_challenge_plugin::{
@@ -194,6 +200,7 @@ pub use ui_modal::{
}; };
pub use ui_tooltip::{Tooltip, UiTooltipPlugin}; pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{ pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
}; };
+33 -82
View File
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8); pub struct OnboardingSlideIndex(pub u8);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the // Slide data — the hotkey slide renders the essential subset of the
// two screens stay in sync without a shared abstraction. // shared table in `crate::hotkeys` (Phase J unified the previously
// diverging copies here and in help_plugin).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
}
/// Most-used shortcuts from the `help_plugin` canonical list.
///
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -313,6 +258,10 @@ fn complete_onboarding(
despawn_screen(commands, screens); despawn_screen(commands, screens);
if let Some(s) = settings { if let Some(s) = settings {
s.0.first_run_complete = true; s.0.first_run_complete = true;
// A fresh install has nothing "new" to announce — stamp the
// running release so the What's-new card (Phase I) only ever
// fires after an actual upgrade.
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
persist(path.map(|p| &p.0), &s.0); persist(path.map(|p| &p.0), &s.0);
} }
} }
@@ -382,20 +331,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
}); });
} }
/// How-to-play body copy, phrased for the platform's input vocabulary —
/// a touch player never left-clicks (Phase H polish; spotted in the
/// emulator smoke of v0.44.0).
#[cfg(target_os = "android")]
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-tap a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Tap Hint in the bottom bar for a suggested move.";
#[cfg(not(target_os = "android"))]
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.";
/// Slide 2 — How to play. /// Slide 2 — How to play.
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Drag cards to play", font_res); spawn_modal_header(card, "Drag cards to play", font_res);
spawn_modal_body_text( spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
card,
"Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.",
TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,
@@ -435,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res); spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as HelpScreen. // Vertical list of `key — description` rows, same chip style as
for row in HOTKEYS { // HelpScreen. Essential subset only — the full table lives on
// the hold-`/` cheat sheet.
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
card.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -815,22 +773,15 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Hotkey list is non-empty (guards against accidental truncation) // Hotkey slide renders a non-empty essential subset (the table's own
// integrity tests live in `crate::hotkeys`)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
fn hotkey_list_is_non_empty() { fn hotkey_slide_subset_is_non_empty() {
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
}
#[test]
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!( assert!(
!row.description.is_empty(), crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
"hotkey description must not be empty" "the onboarding slide needs at least one essential hotkey"
); );
} }
}
} }
+28 -7
View File
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
fn apply_safe_area_anchors( fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>, mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) { ) {
if !insets.is_changed() { if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return; return;
} }
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px // Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
@@ -119,19 +120,31 @@ fn apply_safe_area_anchors(
); );
} }
let top_logical = raw_top.min(max_inset); let top_logical = raw_top.min(max_inset);
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
// time. `base_top` is UI chrome and SHOULD scale with the rest of
// the interface, but the system-bar inset is physical reality —
// pre-divide it so the post-multiplication offset stays exact.
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical); node.top = Val::Px(anchor.base_top + top_logical / ui);
} }
} }
/// The live UI scale, defensively clamped — a zero or negative scale
/// would flip or destroy the inset math.
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
ui_scale.map_or(1.0, |s| s.0.max(0.1))
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying /// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes. /// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors( fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>, mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) { ) {
if !insets.is_changed() { if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
@@ -144,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
); );
} }
let bottom_logical = raw_bottom.min(max_inset); let bottom_logical = raw_bottom.min(max_inset);
// See `apply_safe_area_anchors`: the physical inset is pre-divided
// by the UI scale so a 90% setting can't sink the action bar into
// the gesture zone (and 130% doesn't over-inset it).
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical); node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
} }
} }
@@ -163,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
fn apply_safe_area_to_modal_scrims( fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut scrims: Query<&mut Node, With<ModalScrim>>, mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>, new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) { ) {
let has_new = !new_scrims.is_empty(); let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new { if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height()); let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS // Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely. // value can't push the modal card off the visible area entirely.
let top_logical = (insets.top / scale).min(window_height * 0.25); // Physical insets are pre-divided by the UI scale — see
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25); // `apply_safe_area_anchors`.
let ui = effective_ui_scale(ui_scale.as_deref());
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
for mut node in &mut scrims { for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area // Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With // between the status bar and the gesture/navigation bar. With
@@ -363,6 +363,14 @@ pub(super) fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame. // Text refreshed by `update_touch_input_mode_text` next frame.
} }
SettingsButton::CycleUiScale => {
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_ui_scale_text`; the live
// `bevy::ui::UiScale` resource follows via
// `sync_ui_scale_resource` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => { SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only; settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -139,6 +139,10 @@ struct ReduceMotionText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TouchInputModeText; struct TouchInputModeText;
/// Marks the `Text` node showing the current UI scale percentage.
#[derive(Component, Debug)]
struct UiScaleText;
/// Marks the `Text` node showing the live tooltip-delay value. /// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TooltipDelayText; struct TooltipDelayText;
@@ -277,6 +281,10 @@ enum SettingsButton {
/// (auto-move on tap, default) and `TapToSelect` (first tap selects /// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it). /// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode, ToggleTouchInputMode,
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
/// stays window-fit (Phase K).
CycleUiScale,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through /// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
@@ -354,6 +362,7 @@ impl SettingsButton {
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63, SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::CycleUiScale => 64,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
handle_volume_keys, handle_volume_keys,
record_window_geometry_changes, record_window_geometry_changes,
persist_window_geometry_after_debounce, persist_window_geometry_after_debounce,
// State sync, not UI — runs even under `headless()`
// so the live `bevy::ui::UiScale` always mirrors the
// setting; rides the mutator spine so it is ordered
// after every settings writer this frame.
sync_ui_scale_resource,
) )
.chain() .chain()
.in_set(SettingsMutation), .in_set(SettingsMutation),
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>), update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text, update_reduce_motion_text,
update_touch_input_mode_text, update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_replay_move_interval_text, update_replay_move_interval_text,
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
"scrolling past top must clamp to 0, got {offset}" "scrolling past top must clamp to 0, got {offset}"
); );
} }
// ---------------------------------------------------------------------------
// Phase K: UI scale
// ---------------------------------------------------------------------------
#[test]
fn ui_scale_steps_cycle_and_wrap() {
assert_eq!(next_ui_scale(0.9), 1.0);
assert_eq!(next_ui_scale(1.0), 1.15);
assert_eq!(next_ui_scale(1.15), 1.3);
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
// A hand-edited in-between value advances to the next larger step.
assert_eq!(next_ui_scale(1.05), 1.15);
}
#[test]
fn ui_scale_label_formats_as_percent() {
assert_eq!(ui_scale_label(0.9), "90%");
assert_eq!(ui_scale_label(1.0), "100%");
assert_eq!(ui_scale_label(1.15), "115%");
assert_eq!(ui_scale_label(1.3), "130%");
}
#[test]
fn ui_scale_setting_syncs_the_bevy_resource() {
let mut app = headless_app();
app.insert_resource(UiScale(1.0));
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.ui_scale = 1.3;
app.update();
assert!(
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
"Settings::ui_scale must drive bevy::ui::UiScale"
);
}
#[test]
fn ui_scale_out_of_range_sanitizes_on_load() {
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
let wild = Settings {
ui_scale: 5.0,
..Settings::default()
}
.sanitized();
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
let tiny = Settings {
ui_scale: 0.1,
..Settings::default()
}
.sanitized();
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
}
@@ -326,6 +326,15 @@ fn spawn_accessibility_tab(
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.", "One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res, font_res,
); );
toggle_row(
body,
"UI Scale",
UiScaleText,
ui_scale_label(settings.ui_scale),
SettingsButton::CycleUiScale,
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res); tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
} }
@@ -515,6 +515,58 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
} }
} }
/// The four UI-scale steps the settings row cycles through (Phase K).
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
/// hand-edited value between steps advances to the first step larger
/// than it, so the cycle always makes visible progress.
pub(super) fn next_ui_scale(current: f32) -> f32 {
for step in UI_SCALE_STEPS {
if step > current + 0.001 {
return step;
}
}
UI_SCALE_STEPS[0]
}
/// Display string for the UI-scale row, e.g. `"115%"`.
pub(super) fn ui_scale_label(scale: f32) -> String {
format!("{:.0}%", scale * 100.0)
}
/// Refreshes the live UI-scale value text whenever settings change.
pub(super) fn update_ui_scale_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = ui_scale_label(settings.0.ui_scale);
}
}
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
/// resource — at startup (first change tick) and whenever the setting
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
/// `Option`; the table is unaffected either way (`compute_layout`
/// owns world-space sizing, not UI scale).
pub(super) fn sync_ui_scale_resource(
settings: Res<SettingsResource>,
ui_scale: Option<ResMut<UiScale>>,
) {
let Some(mut ui_scale) = ui_scale else { return };
if !settings.is_changed() {
return;
}
let target = settings.0.ui_scale;
if (ui_scale.0 - target).abs() > f32::EPSILON {
ui_scale.0 = target;
}
}
/// Display string for the "Smart window size" toggle. The argument /// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying /// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives /// `disable_smart_default_size` field) so reading the label gives
+243 -7
View File
@@ -14,6 +14,9 @@
//! filesystem-based; the plugin is gated out on wasm32 alongside //! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`. //! `SyncPlugin`.
use std::collections::HashMap;
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error; use thiserror::Error;
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>); struct StoreBaseUrl(Option<String>);
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
/// previews survive closing and reopening the store, so a revisit
/// renders instantly without refetching.
#[derive(Resource, Default)]
struct PreviewCache(HashMap<String, Handle<Image>>);
/// One in-flight preview download: raw PNG bytes or the fetch error.
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
/// In-flight preview downloads, one per catalog entry that advertises
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
/// theme id the bytes belong to.
#[derive(Resource, Default)]
struct PreviewTasks(Vec<(String, PreviewFetch)>);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>() .init_resource::<CatalogTask>()
.init_resource::<InstallTask>() .init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>() .init_resource::<StoreBaseUrl>()
.init_resource::<PreviewCache>()
.init_resource::<PreviewTasks>()
// Esc-close reads keyboard input; register defensively so // Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests. // the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>() .init_resource::<ButtonInput<KeyCode>>()
@@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
( (
handle_open_request, handle_open_request,
poll_catalog_task, poll_catalog_task,
poll_preview_tasks,
handle_install_buttons, handle_install_buttons,
poll_install_task, poll_install_task,
handle_close_button, handle_close_button,
@@ -176,6 +197,7 @@ fn handle_open_request(
mut catalog_task: ResMut<CatalogTask>, mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>, mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -207,16 +229,28 @@ fn handle_open_request(
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await }) rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
})); }));
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref()); spawn_store_modal(
&mut commands,
&catalog_state,
None,
&previews,
font_res.as_deref(),
);
} }
/// Polls the catalog fetch; on completion updates [`CatalogState`] and /// Polls the catalog fetch; on completion updates [`CatalogState`],
/// kicks off preview downloads for entries not yet in the cache, and
/// rebuilds the modal if it is still open. /// rebuilds the modal if it is still open.
#[allow(clippy::too_many_arguments)]
fn poll_catalog_task( fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>, mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>, mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>, registry: Option<Res<ThemeRegistry>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
previews: Res<PreviewCache>,
mut preview_tasks: ResMut<PreviewTasks>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -229,7 +263,29 @@ fn poll_catalog_task(
catalog_task.0 = None; catalog_task.0 = None;
*catalog_state = match result { *catalog_state = match result {
Ok(entries) => CatalogState::Loaded(entries), Ok(entries) => {
// Fetch previews for anything new. Decorative — failures
// just leave the row text-only, so errors only log.
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
for entry in entries
.iter()
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
{
let rt = rt.0.clone();
let base_url = base_url.to_owned();
let entry = entry.clone();
preview_tasks.0.push((
entry.id.clone(),
AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async {
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
})
}),
));
}
}
CatalogState::Loaded(entries)
}
Err(e) => { Err(e) => {
warn!("theme store: catalog fetch failed: {e}"); warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string()) CatalogState::Error(e.to_string())
@@ -241,10 +297,69 @@ fn poll_catalog_task(
&mut commands, &mut commands,
&catalog_state, &catalog_state,
registry.as_deref(), registry.as_deref(),
&previews,
font_res.as_deref(), font_res.as_deref(),
); );
} }
/// Polls in-flight preview downloads. Each finished PNG is decoded into
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
/// once per frame that added at least one preview, so thumbnails pop in
/// as they arrive. Failures log and leave the row text-only. Skipped
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
#[allow(clippy::too_many_arguments)]
fn poll_preview_tasks(
mut preview_tasks: ResMut<PreviewTasks>,
mut previews: ResMut<PreviewCache>,
images: Option<ResMut<Assets<Image>>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if preview_tasks.0.is_empty() {
return;
}
let Some(mut images) = images else {
return;
};
let mut added = false;
preview_tasks.0.retain_mut(|(id, task)| {
let Some(result) = future::block_on(future::poll_once(task)) else {
return true; // still downloading
};
match result {
Ok(bytes) => match image::load_from_memory(&bytes) {
Ok(dyn_img) => {
let handle = images.add(Image::from_dynamic(
dyn_img,
true,
RenderAssetUsages::RENDER_WORLD,
));
previews.0.insert(id.clone(), handle);
added = true;
}
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
},
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
}
false
});
if added {
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
&previews,
font_res.as_deref(),
);
}
}
/// Starts a download + import task when an Install button is pressed. /// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored. /// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons( fn handle_install_buttons(
@@ -308,6 +423,7 @@ fn poll_install_task(
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -343,6 +459,7 @@ fn poll_install_task(
&mut commands, &mut commands,
&catalog_state, &catalog_state,
registry.as_deref(), registry.as_deref(),
&previews,
font_res.as_deref(), font_res.as_deref(),
); );
} }
@@ -378,11 +495,12 @@ fn rebuild_open_modal(
commands: &mut Commands, commands: &mut Commands,
catalog_state: &CatalogState, catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>, registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
for entity in screens { for entity in screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, font_res); spawn_store_modal(commands, catalog_state, registry, previews, font_res);
} }
} }
@@ -391,6 +509,7 @@ fn spawn_store_modal(
commands: &mut Commands, commands: &mut Commands,
catalog_state: &CatalogState, catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>, registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
let body_font = TextFont { let body_font = TextFont {
@@ -433,7 +552,15 @@ fn spawn_store_modal(
for entry in entries { for entry in entries {
let installed = let installed =
registry.is_some_and(|registry| registry.find(&entry.id).is_some()); registry.is_some_and(|registry| registry.find(&entry.id).is_some());
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res); spawn_store_row(
card,
entry,
installed,
previews.0.get(&entry.id),
&body_font,
&caption_font,
font_res,
);
} }
} }
} }
@@ -452,12 +579,20 @@ fn spawn_store_modal(
commands.entity(scrim).insert(ScrimDismissible); commands.entity(scrim).insert(ScrimDismissible);
} }
/// One catalog row: name + author/size caption on the left, Install /// Height of a row's preview thumbnail in logical pixels; the width
/// button (or "Installed" caption) on the right. /// follows the theme's own `card_aspect` so a preview is never
/// stretched.
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
/// One catalog row: preview thumbnail (when downloaded) + name +
/// author/size caption on the left, Install button (or "Installed"
/// caption) on the right.
#[allow(clippy::too_many_arguments)]
fn spawn_store_row( fn spawn_store_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry, entry: &ThemeCatalogEntry,
installed: bool, installed: bool,
preview: Option<&Handle<Image>>,
body_font: &TextFont, body_font: &TextFont,
caption_font: &TextFont, caption_font: &TextFont,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
@@ -477,6 +612,25 @@ fn spawn_store_row(
)) ))
.with_children(|row| { .with_children(|row| {
row.spawn(Node { row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|left| {
if let Some(handle) = preview {
let (aw, ah) = entry.card_aspect;
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
left.spawn((
ImageNode::new(handle.clone()),
Node {
width: Val::Px(width),
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
..default()
},
));
}
left.spawn(Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2,
..default() ..default()
@@ -497,6 +651,7 @@ fn spawn_store_row(
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
}); });
});
if installed { if installed {
row.spawn(( row.spawn((
Text::new("Installed"), Text::new("Installed"),
@@ -661,4 +816,85 @@ mod tests {
)); ));
assert_eq!(screen_count(&mut app), 1); assert_eq!(screen_count(&mut app), 1);
} }
// -----------------------------------------------------------------------
// Phase H part 2: preview thumbnails
// -----------------------------------------------------------------------
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: id.into(),
name: id.into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: 1024,
sha256: "00".into(),
download_url: format!("/api/themes/{id}/download"),
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
}
}
/// A row whose theme id is in the [`PreviewCache`] renders an
/// `ImageNode` thumbnail; a row without one stays text-only.
#[test]
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
let mut app = headless_app();
let cached = catalog_entry("with-preview", true);
let uncached = catalog_entry("without-preview", false);
let mut cache = PreviewCache::default();
cache
.0
.insert(cached.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![cached, uncached]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let thumbs = app
.world_mut()
.query::<&ImageNode>()
.iter(app.world())
.count();
assert_eq!(
thumbs, 1,
"exactly the cached entry must render a preview thumbnail"
);
}
/// The thumbnail width follows the theme's own card aspect so a
/// preview never renders stretched.
#[test]
fn preview_thumbnail_width_follows_card_aspect() {
let mut app = headless_app();
let mut entry = catalog_entry("wide", true);
entry.card_aspect = (1, 1); // square theme art
let mut cache = PreviewCache::default();
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![entry]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ImageNode>>()
.single(app.world())
.expect("thumbnail node must exist");
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
assert_eq!(
node.width,
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
"a 1:1 aspect must yield a square thumbnail"
);
}
} }
+527
View File
@@ -0,0 +1,527 @@
//! One-shot "What's new" card on the first launch after an update
//! (Phase I of the 2026-07 UI redesign).
//!
//! ObtainX updates install silently, so shipped features go unnoticed —
//! nobody found the theme store on their own. On the first launch where
//! the running release differs from `Settings::last_seen_whats_new`,
//! this plugin shows a single dismissible card summarising the latest
//! changelog section, then records the version so the card never
//! repeats. Fresh installs never see it: onboarding completion stamps
//! the current version silently, so the card only ever describes an
//! *upgrade*.
//!
//! # Launch beat
//!
//! Splash → (first run only: onboarding) → **what's new** → Home →
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
//! way it waits for the onboarding modal.
//!
//! # Version source
//!
//! The embedded `CHANGELOG.md`'s topmost release section provides both
//! the card's content and the "current version" for change detection —
//! one source of truth, no build-time version plumbing. (The APK
//! `versionName` comes from the release tag at package time and never
//! reaches Rust; the workspace `Cargo.toml` version is static.)
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::font_plugin::FontResource;
use crate::onboarding_plugin::OnboardingScreen;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::{
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_theme::{
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
};
/// The changelog ships inside the binary — small, changes only at
/// release cadence, and the card must work offline (§4.2).
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
/// Most bullets shown on the card; a giant release stays skimmable.
const MAX_CARD_BULLETS: usize = 8;
/// Marker on the What's-new modal's scrim root.
#[derive(Component, Debug)]
pub struct WhatsNewScreen;
/// Marker on the card's "Got it" button.
#[derive(Component, Debug)]
struct WhatsNewCloseButton;
/// `true` until this plugin has made its launch-beat decision — either
/// the card was shown and dismissed, or there was nothing to show.
/// `spawn_home_on_launch` waits on this so the card gets the beat
/// before Home.
#[derive(Resource, Debug)]
pub struct WhatsNewPending(pub bool);
impl Default for WhatsNewPending {
fn default() -> Self {
Self(true)
}
}
/// One display line of the card.
#[derive(Debug, PartialEq, Eq)]
enum NoteLine {
/// A `###` section heading ("Added", "Fixed", …).
Heading(String),
/// The lead sentence of one changelog bullet.
Bullet(String),
}
/// The changelog's topmost release section, reduced to card content.
#[derive(Debug)]
struct ReleaseNotes {
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
version: String,
lines: Vec<NoteLine>,
}
/// The version of the topmost changelog release — the engine's notion
/// of "the running release". Empty only if the changelog is malformed.
pub fn current_release_version() -> String {
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
}
/// Parses the first `## [x.y.z]` section of `changelog` into card
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
/// are dropped (players don't care about CI); each bullet is reduced
/// to its lead sentence with markdown emphasis stripped.
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
let mut lines_iter = changelog.lines();
let mut version = None;
for line in lines_iter.by_ref() {
if let Some(rest) = line.strip_prefix("## [") {
let (v, _) = rest.split_once(']')?;
if v.eq_ignore_ascii_case("Unreleased") {
continue;
}
version = Some(v.to_string());
break;
}
}
let version = version?;
let mut lines = Vec::new();
let mut skipping_section = false;
let mut current_bullet: Option<String> = None;
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
if let Some(text) = bullet.take() {
lines.push(NoteLine::Bullet(lead_sentence(&text)));
}
};
for line in lines_iter {
if line.starts_with("## [") {
break; // next (older) release section
}
if let Some(heading) = line.strip_prefix("### ") {
flush(&mut current_bullet, &mut lines);
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
if !skipping_section {
lines.push(NoteLine::Heading(heading.trim().to_string()));
}
continue;
}
if skipping_section {
continue;
}
if let Some(rest) = line.strip_prefix("- ") {
flush(&mut current_bullet, &mut lines);
current_bullet = Some(rest.trim().to_string());
} else if current_bullet.is_some() && !line.trim().is_empty() {
// Wrapped continuation of the current bullet.
if let Some(bullet) = current_bullet.as_mut() {
bullet.push(' ');
bullet.push_str(line.trim());
}
}
}
flush(&mut current_bullet, &mut lines);
Some(ReleaseNotes { version, lines })
}
/// Strips markdown emphasis / code ticks and truncates to the first
/// sentence — changelog bullets lead with a bold summary sentence, and
/// that is exactly the card-sized version.
fn lead_sentence(bullet: &str) -> String {
let stripped: String = bullet.replace("**", "").replace('`', "");
// Trailing "(#123)" references never survive the sentence cut, but
// guard against a bullet that is only a reference.
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
stripped[..end].trim().trim_end_matches('.').to_string()
}
/// Registers the launch-gate spawn system and the dismiss handlers.
pub struct WhatsNewPlugin;
impl Plugin for WhatsNewPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WhatsNewPending>()
.init_resource::<ButtonInput<KeyCode>>()
.add_systems(
Update,
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
);
}
}
/// Shows the card once the launch surface is clear, or stands down when
/// there is nothing to show. See the module docs for the exact beat.
///
/// The seen-version stamp persists on *spawn*, not dismissal — if the
/// app dies with the card open the player has still seen it once, and
/// the card must never nag.
#[allow(clippy::too_many_arguments)]
fn maybe_spawn_whats_new(
mut commands: Commands,
mut pending: ResMut<WhatsNewPending>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
onboarding: Query<(), With<OnboardingScreen>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
other_scrims: Query<(), With<ModalScrim>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
font_res: Option<Res<FontResource>>,
screens: Query<(), With<WhatsNewScreen>>,
) {
if !pending.0 {
return;
}
if !screens.is_empty() {
// Card already open — the seen-version stamp landed on spawn,
// so without this guard the next frame would read "already
// seen" and release the beat under the open card.
return;
}
if !splash.is_empty()
|| !onboarding.is_empty()
|| !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
{
return;
}
let Some(settings) = settings.as_mut() else {
// Headless / no settings wired: nothing to compare against.
pending.0 = false;
return;
};
if !settings.0.first_run_complete {
// Fresh install mid-onboarding — completion stamps the version
// itself (see `complete_onboarding`), which resolves this gate
// on a later frame without ever showing the card.
return;
}
let Some(notes) = parse_latest_release(CHANGELOG) else {
pending.0 = false;
return;
};
if settings.0.last_seen_whats_new == notes.version {
pending.0 = false;
return;
}
if !other_scrims.is_empty() {
// Another modal owns the beat right now; try again next frame.
return;
}
spawn_whats_new_card(&mut commands, &notes, font_res.as_deref());
settings.0.last_seen_whats_new = notes.version.clone();
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("whats-new: failed to persist seen version: {e}");
}
// `pending` stays true until dismissal so Home keeps waiting.
}
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
fn handle_whats_new_close(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<WhatsNewScreen>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
mut pending: ResMut<WhatsNewPending>,
) {
if screens.is_empty() {
return;
}
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
if !click && !esc {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
pending.0 = false;
}
/// Spawns the card: header with the version, the parsed changelog
/// lines, and a "Got it" action.
fn spawn_whats_new_card(
commands: &mut Commands,
notes: &ReleaseNotes,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_heading = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_bullet = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
// running the close handler, which would leave the launch beat
// held and Home never spawning. Esc and "Got it" both release it.
let title = format!("What's new in v{}", notes.version);
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, &title, font_res);
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
width: Val::Percent(100.0),
max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(),
..default()
})
.with_children(|body| {
let mut bullets_shown = 0usize;
for line in &notes.lines {
match line {
NoteLine::Heading(heading) => {
body.spawn((
Text::new(heading.clone()),
font_heading.clone(),
TextColor(TEXT_SECONDARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
NoteLine::Bullet(text) => {
if bullets_shown >= MAX_CARD_BULLETS {
continue;
}
bullets_shown += 1;
body.spawn((
Text::new(format!("\u{2022} {text}")),
font_bullet.clone(),
TextColor(TEXT_PRIMARY),
));
}
}
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
WhatsNewCloseButton,
"Got it",
None,
ButtonVariant::Primary,
font_res,
);
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use solitaire_data::Settings;
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
## [0.45.0] 2026-07-13\n\n### Added\n\n- Old stuff.\n";
#[test]
fn parses_top_section_version_and_skips_unreleased() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert_eq!(notes.version, "0.46.0");
}
#[test]
fn drops_internal_sections_and_older_releases() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
let headings: Vec<&str> = notes
.lines
.iter()
.filter_map(|l| match l {
NoteLine::Heading(h) => Some(h.as_str()),
_ => None,
})
.collect();
assert_eq!(headings, vec!["Added", "Fixed"]);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
"Internal bullets must not reach the card"
);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
"older release sections must not bleed in"
);
}
#[test]
fn bullets_reduce_to_their_lead_sentence() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
);
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
);
}
#[test]
fn embedded_changelog_parses_to_a_nonempty_version() {
let version = current_release_version();
assert!(
!version.is_empty(),
"the real CHANGELOG.md must yield a version"
);
assert!(
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
"version must be bare (no v prefix): {version}"
);
}
fn app_with(settings: Settings) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
app.insert_resource(SettingsResource(settings));
app.update();
app
}
fn card_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WhatsNewScreen>()
.iter(app.world())
.count()
}
#[test]
fn upgrade_shows_card_and_stamps_version() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: "0.1.0".into(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
assert_eq!(
app.world()
.resource::<SettingsResource>()
.0
.last_seen_whats_new,
current_release_version(),
"the seen version must stamp on spawn"
);
assert!(
app.world().resource::<WhatsNewPending>().0,
"the launch beat stays held until dismissal"
);
}
#[test]
fn seen_version_shows_nothing_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: current_release_version(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
!app.world().resource::<WhatsNewPending>().0,
"nothing to show must release the launch beat"
);
}
#[test]
fn fresh_install_waits_for_onboarding_and_never_shows() {
let mut app = app_with(Settings {
first_run_complete: false,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
app.world().resource::<WhatsNewPending>().0,
"mid-onboarding the beat stays held"
);
// Onboarding completion stamps the version (mirrored from
// complete_onboarding) — afterwards the card must stand down.
{
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
settings.0.first_run_complete = true;
settings.0.last_seen_whats_new = current_release_version();
}
app.update();
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
#[test]
fn got_it_dismisses_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1);
let button = app
.world_mut()
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
.single(app.world())
.expect("Got it button must exist");
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
app.update();
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
}