Compare commits

...

28 Commits

Author SHA1 Message Date
funman300 b4845564af Merge pull request 'refactor(engine): ambiguity gate covers the full cluster — batches 1-3' (#188) from refactor/ambiguity-gate-cluster-v2 into master
Test / fmt (push) Successful in 3s
Test / test (push) Successful in 4m19s
Build and Deploy / build-and-push (push) Failing after 6m40s
Web E2E / web-e2e (push) Successful in 10m57s
2026-07-17 00:48:15 +00:00
funman300 838342649c refactor(engine): ambiguity gate covers the full cluster — batches 1-3, 585 pairs burned to zero
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m13s
Port the July 7 gate-extender branch onto current master (the You-hub,
touch action bar, glass tab bar, Phase L, and hint-ghost work all landed
since) and finish batch 3. cluster_app now includes the mode, replay,
input, radial, tooltip, cursor, touch-selection, and safe-area plugins on
top of batches 1-2; the 585 ambiguous pairs the expansion exposed are
annotated down to zero and the gate stays assert_eq!(count, 0).

New ordering machinery:
- ReplayPlayback: playback driver chain on the pre-mutation spine
  (PointerInput < ReplayPlayback < overlay chain < GameMutation)
- ModeStart: mode-start handlers between the stats abandon-recorder and
  GameMutation; ChallengeCompletion < DailyCompletion < WeeklyGoalsEval
  pinned inside ProgressUpdate so `.after(ProgressUpdate)` readers see
  settled progress
- PointerInput: public set wrapping the input chain; AbandonRecord on the
  stats abandon-recorder; PendingHint around the async hint pipeline;
  HintGhostFx for the ghost chain (after the full visual spine)
- Clubs: MoveRequestWriters, WarningToastWriters join the existing
  request/toast writer clubs; UiTextFx grows the overlay/tooltip/safe-area
  chrome painters; SettingsAccess grows the lag-tolerant settings readers

Gate: cargo test --workspace green (993 engine tests), clippy
--all-targets -D warnings clean, rustfmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:42:46 -07:00
funman300 7f604f5df6 Merge pull request 'feat(engine): surface upstream move-type counters and replay seek' (#173) from feat/upstream-stat-counters into master
Test / fmt (push) Successful in 9s
Test / test (push) Successful in 5m28s
Build and Deploy / build-and-push (push) Failing after 9m7s
Web E2E / web-e2e (push) Failing after 20s
2026-07-16 23:53:11 +00:00
funman300 099b7a263f docs(changelog): cut 0.48.1 — reduce transparency, HC glass, drag duck-away
Android Release / build-apk (push) Successful in 4m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:27:47 -07:00
funman300 a0776f79b1 Merge pull request 'feat(engine): glass bar accessibility + drag duck-away' (#187) from feat/glass-bar-a11y into master
Test / fmt (push) Successful in 3s
Build and Deploy / build-and-push (push) Failing after 7m53s
Test / test (push) Successful in 4m33s
Web E2E / web-e2e (push) Successful in 7m31s
2026-07-15 22:23:24 +00:00
funman300 4b484f899a feat(engine): duck the glass tab bar during card drags
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m24s
While a drag is committed the floating bar slides below the screen
edge (UiTransform translation - no layout dirtying, no fight with the
safe-area anchor over Node::bottom) so it never occludes a drop target
on the bottom tableau rows; it slides back on release. Same tween rate
as the label reveal, snaps under reduce-motion. Desktop docked bar is
untouched (no TabBarDuck component).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:23:05 -07:00
funman300 c2256582cd feat(engine): reduce-transparency + high-contrast support for glass chrome
New Settings::reduce_transparency_mode (serde-default, Accessibility
toggle row) swaps every GlassSurface's gradients for a flat opaque
BG_ELEVATED fill; high-contrast mode now also boosts the glass fill
opacity and rim luminance to BORDER_SUBTLE_HC levels. Both applied by
settings_plugin::update_glass_surfaces, which retargets the gradients
in place on toggle or on newly spawned glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:16:32 -07:00
funman300 b405e54bb0 docs(changelog): cut 0.48.0 — glass tab bar, standard states, drag-hotkey fix
Android Release / build-apk (push) Successful in 4m15s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:03:27 -07:00
funman300 5c97913868 Merge pull request 'feat(engine): floating liquid-glass touch tab bar' (#186) from feat/glass-tab-bar into master
Test / fmt (push) Successful in 3s
Build and Deploy / build-and-push (push) Failing after 7m52s
Test / test (push) Successful in 4m53s
Web E2E / web-e2e (push) Successful in 8m13s
2026-07-15 21:59:21 +00:00
funman300 c8fc3e7a09 feat(engine): replace docked touch bar with floating glass tab bar
Test / fmt (pull_request) Successful in 7s
Test / test (pull_request) Successful in 4m50s
Touch layout only (desktop bar unchanged): a floating glass pill
(Undo / Draw / Hint / Pause) plus a detached circular Menu button,
margin above the bottom edge on top of the safe-area inset. Draw is
the persistently expanded accent pill (icon + label); other buttons
are icon-only and slide their label out while pressed, snapping under
reduce-motion. Buttons keep the existing marker components so click
handlers, tooltips, focus ring, and chrome toggle work unchanged.
Icons: None degrades to text-fallback labels (MinimalPlugins tests /
SVG regression), keeping the bar fully usable.

Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the
legacy touch bar spawn path and primary-button metrics are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:57:21 -07:00
funman300 7c2daa1e32 feat(engine): add stroke-based SVG icon set for the touch tab bar
Five white 24-box stroke icons (undo/draw/hint/pause/menu) rasterised
through the existing usvg/resvg pipeline; tinted at spawn time via
ImageNode::color. Avoids FiraMono's missing icon glyphs on Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:42:37 -07:00
funman300 37919cfe17 feat(engine): add ui_glass liquid-glass material module
Layered bevy_ui treatment (sheen gradient, specular BorderGradient rim,
BoxShadow) approximating frosted glass without shaders or backdrop
sampling - WebGL2-safe by construction. Layout callers depend only on
glass_surface() + GLASS_BORDER_PX so the material can be swapped later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:40:39 -07:00
funman300 9081a9916c Merge pull request 'fix(engine): swallow game-mutating hotkeys during pointer drags' (#185) from fix/keyboard-during-drag into master
Test / fmt (push) Successful in 5s
Build and Deploy / build-and-push (push) Failing after 7m31s
Test / test (push) Successful in 4m41s
Web E2E / web-e2e (push) Successful in 10m15s
2026-07-14 18:49:17 +00:00
funman300 5430d4c3ab fix(engine): swallow game-mutating hotkeys during pointer drags
Test / fmt (pull_request) Successful in 6s
Test / test (pull_request) Successful in 4m43s
U (undo), N (new game), Z (zen), D/Space (draw) and G (forfeit) were
not gated on an active drag, so pressing one mid-drag fired a
StateChangedEvent whose card re-sync inserted a `CardAnim` on the
dragged cards — fighting `follow_drag`'s per-frame Transform writes
(jittering XY, animated Z) and leaving `DragState` origin indices
stale against the mutated state.

`handle_keyboard_core` and `handle_keyboard_forfeit` now reuse the
mutual-exclusion guard `handle_selection_keys` already documents: skip
while `DragState` is non-idle, unless it is the keyboard-lift sentinel
(`KEYBOARD_DRAG_TOUCH_ID`), whose lift is already dropped cleanly by
`clear_selection_on_state_change`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:35:47 -07:00
funman300 fc4ea17789 Merge pull request 'polish(engine): standard empty/loading/error states (Phase L)' (#184) from feat/empty-states into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m51s
Build and Deploy / build-and-push (push) Failing after 8m20s
Web E2E / web-e2e (push) Successful in 9m18s
2026-07-14 03:08:55 +00:00
funman300 02bcc8b4af polish(engine): standard empty/loading/error states (Phase L)
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m20s
New ui_modal::spawn_empty_state — glyph + headline + optional detail,
centred, with next-step actions composed alongside rather than
configured in. Glyphs restricted to FiraMono-covered ranges (suits,
arrows, ASCII) per the section-10/11 Android constraint.

Swept every improvised surface onto it:
- Theme store: loading / error / empty-catalog branches
- Leaderboard: fetching / error / be-the-first branches
- You-hub Replays tab: dedicated no-replays state (selector and
  actions no longer spawn dead controls when history is empty)
- Stats: first-launch nudge above the em-dash grid

The Account tab's sync row was deliberately NOT swept: it is a live
status + action row, not an empty state, and its deeper treatment
belongs to Phase M's sync-transparency work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:03:09 -07:00
funman300 d4d0bde0c0 docs(changelog): cut 0.47.0 — What's-new card, tips, UI scale, cheat sheet
Android Release / build-apk (push) Successful in 5m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:04:24 -07:00
funman300 cbbab3d09f Merge pull request 'feat(engine): hold-/ hotkey cheat sheet + unified binding table (Phase J)' (#183) from feat/keyboard-completeness into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m53s
Build and Deploy / build-and-push (push) Failing after 7m49s
Web E2E / web-e2e (push) Successful in 8m48s
2026-07-14 02:04:06 +00:00
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 b2581024f3 feat(engine): surface upstream move-type counters and replay seek
Test / test (pull_request) Successful in 36m35s
Two unused-for-free upstream card_game/klondike features:

- GameState now exposes the granular KlondikeStats counters
  (move_to_foundation_count, move_to_tableau_count,
  move_from_foundation_count, flip_up_count) and the win modal shows a
  quiet per-move-type recap line built from them (e.g. "21 to
  foundation - 14 tableau moves - 9 flips")
- wasm ReplayPlayer gains seek(step): clamped jump to any position,
  rewinding via a stored copy of the recorded deal instead of reparsing
  the replay JSON; replay.js Prev now uses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:33 -07:00
62 changed files with 4450 additions and 471 deletions
+87
View File
@@ -6,6 +6,93 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.48.1] — 2026-07-15
### Added
- **Reduce Transparency.** Settings → Accessibility gains a toggle that
replaces the see-through glass toolbar with a solid panel, so cards
behind it can never reduce readability. High Contrast mode now also
makes the glass markedly more opaque with a brighter outline. (#187)
### Changed
- **The toolbar gets out of your way.** While you're dragging a card,
the floating toolbar slides off the bottom edge and returns when you
drop — bottom-row piles are never hidden behind it. Instant (no
animation) when Reduce Motion is on. (#187)
## [0.48.0] — 2026-07-15
### Changed
- **Floating glass tab bar** (touch). The bottom action bar is now a
floating translucent pill — Undo, Draw, Hint, and Pause — with a
separate round Menu button beside it, hovering above the bottom edge
instead of docking to it. Draw stays expanded as the highlighted
action; the other buttons reveal their label while held (instantly,
with reduce-motion on). Same actions, same behaviour — new clothes.
(#186)
- **Consistent empty, loading, and error screens.** The theme store,
leaderboard, and replay list now share one visual language for
"nothing here yet", "still loading", and "something went wrong" —
each with a next step instead of a dead end. (#184)
### Fixed
- **Hotkeys during drags.** Pressing Undo, New Game, Draw, or a mode
key mid-drag no longer fights the card you're holding — game-mutating
hotkeys are ignored until the card is dropped. (#185)
## [0.47.0] — 2026-07-13
### Added
- **"What's new" on update.** The first launch after an update shows a
one-time card summarising what changed — like this one, right now.
Dismiss it and it never repeats; fresh installs never see it. (#180)
- **Situational tips.** Two one-time teaches that fire from play, not a
tour: staring at a stuck board for a while points you at Hint, and an
engaged game quietly mentions the long-press quick-action menu —
unless you've already found it yourself. (#181)
- **UI scale.** Settings → Accessibility now has a UI Scale control
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
element live. The table always fits your screen regardless. (#182)
- **Hold `/` for shortcuts** (desktop). A reference card of every
keyboard binding appears while `/` is held. The onboarding tour and
this card now share one binding table, so they can never disagree —
the tour's copy had drifted from reality. (#183)
## [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
### Added
+26
View File
@@ -490,6 +490,32 @@ impl GameState {
self.session.stats().stats().recycle_count()
}
/// Number of cards moved onto foundations this game, read from the
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
/// not rolled back on undo.
pub fn move_to_foundation_count(&self) -> u32 {
self.session.stats().stats().move_to_foundation_count()
}
/// Number of stacks moved onto tableaus (from stock or another tableau)
/// this game, read from the upstream session stats. Cumulative — not
/// rolled back on undo.
pub fn move_to_tableau_count(&self) -> u32 {
self.session.stats().stats().move_to_tableau_count()
}
/// Number of cards taken back off a foundation this game, read from the
/// upstream session stats. Cumulative — not rolled back on undo.
pub fn move_from_foundation_count(&self) -> u32 {
self.session.stats().stats().move_from_foundation_count()
}
/// Number of face-down cards revealed (flipped up) this game, read from
/// the upstream session stats. Cumulative — not rolled back on undo.
pub fn flip_up_count(&self) -> u32 {
self.session.stats().stats().flip_up_bonus_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length.
pub fn move_count(&self) -> u32 {
+50
View File
@@ -154,6 +154,14 @@ pub struct Settings {
/// `#[serde(default)]`.
#[serde(default)]
pub reduce_motion_mode: bool,
/// When `true`, replaces translucent "glass" chrome (the floating
/// touch tab bar) with opaque fills so table content behind it can
/// never reduce label/icon contrast. The accessibility counterpart
/// of iOS "Reduce Transparency". Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` thanks
/// to `#[serde(default)]`.
#[serde(default)]
pub reduce_transparency_mode: bool,
/// Window size and screen position to restore on next launch. `None`
/// means "use platform defaults" — set on first run, then populated
/// as the player resizes / moves the window. Older `settings.json`
@@ -257,6 +265,32 @@ pub struct Settings {
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)]
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
/// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this
@@ -365,6 +399,16 @@ fn default_replay_move_interval_secs() -> f32 {
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 {
1
}
@@ -415,6 +459,7 @@ impl Default for Settings {
color_blind_mode: false,
high_contrast_mode: false,
reduce_motion_mode: false,
reduce_transparency_mode: false,
window_geometry: None,
selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false,
@@ -425,6 +470,10 @@ impl Default for Settings {
replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None,
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_opted_in: false,
take_from_foundation: true,
@@ -460,6 +509,7 @@ impl Settings {
replay_move_interval_secs: self
.replay_move_interval_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,
..self
}
+49 -5
View File
@@ -67,6 +67,16 @@ pub struct AchievementsResource(pub Vec<AchievementRecord>);
#[derive(Resource, Debug, Clone)]
pub struct AchievementsStoragePath(pub Option<PathBuf>);
/// Set wrapping the win-path achievement evaluator. It writes
/// `AchievementsResource`/`ProgressResource` and emits unlock events; UI
/// readers of those (HUD popovers, the stats/achievements panels) order
/// themselves `.after(AchievementEval)`. The replay-completion evaluator is
/// deliberately NOT a member: it runs after `AutoComplete`, and panel chains
/// that need `.after(AchievementEval)` run before `AutoComplete` — putting
/// both in one set would close an ordering cycle (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AchievementEval;
pub struct AchievementPlugin {
pub storage_path: Option<PathBuf>,
}
@@ -119,9 +129,13 @@ impl Plugin for AchievementPlugin {
.add_systems(
Update,
evaluate_on_win
.in_set(AchievementEval)
.after(GameMutation)
.after(StatsUpdate)
.after(ProgressUpdate),
.after(ProgressUpdate)
// The unlock/XP/level-up events it emits are drained by
// the toast pipeline this frame (#143).
.before(crate::animation_plugin::AnimFx),
)
// Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run
@@ -131,20 +145,50 @@ impl Plugin for AchievementPlugin {
Update,
fire_achievement_onboarding_toast
.after(GameMutation)
.after(StatsUpdate),
.after(StatsUpdate)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters)
// Writes the one-shot onboarding flag into
// SettingsResource: board painters re-read settings, so
// land the write first; UiTextFx members read disjoint
// settings fields (reduce-motion), so writer/reader
// order there is provably irrelevant (#143).
.before(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
scroll_achievements_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build.
.add_systems(Update, evaluate_cinephile_on_replay_completion);
.add_systems(
Update,
// Shares the unlock stream and AchievementsResource with
// evaluate_on_win; ordered after it and before the toast
// pipeline that drains the unlock events (#143).
evaluate_cinephile_on_replay_completion
.after(evaluate_on_win)
// The stats overlay's watch-replay button (ordered
// before AutoComplete) writes ReplayPlaybackState this
// system reads.
.after(crate::auto_complete_plugin::AutoComplete)
.before(crate::animation_plugin::AnimFx),
);
}
}
+51 -9
View File
@@ -179,10 +179,13 @@ pub struct ToastStackRoot;
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 };
/// before safe-area insets. Clears the floating glass tab bar on touch
/// (its margin + height + breathing room); desktop's docked bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
} else {
72.0
};
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
@@ -227,6 +230,14 @@ fn adopt_toasts_into_stack(
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin;
/// Set wrapping this plugin's Update chain (card slide/cascade animation and
/// the toast pipeline). Ordered after [`crate::card_animation::CardTweenFx`]
/// on the shared visual spine — members write card `Transform`s, which every
/// other painter set also touches — and after the toast/stat/progress writer
/// sets so the toast handlers drain this frame's events (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AnimFx;
impl Plugin for AnimationPlugin {
fn build(&self, app: &mut App) {
// Register the events this plugin consumes so tests that don't include
@@ -250,12 +261,36 @@ impl Plugin for AnimationPlugin {
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.configure_sets(
Update,
AnimFx
// Visual spine: …BoardVisuals < MarkerVisuals <
// CardTweenFx < AnimFx < FeedbackFx (#143). GameMutation
// ordering is inherited transitively.
.after(crate::card_animation::CardTweenFx)
.after(crate::table_plugin::MarkerVisuals)
// Stats/progress must settle before the win/level-up
// toast handlers read their events. (No edge against
// InfoToastWriters: toast writers exist on both sides
// of this set — enqueue_toasts drains via cursor and
// joins the club instead; a toast surfacing one frame
// later is invisible.)
.after(crate::stats_plugin::StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(GameMutation)
// HUD text pulses are disjoint from the card entities
// this set animates (see UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems(
Update,
(
advance_card_anims,
sync_slide_duration,
handle_win_cascade,
// Both drain SettingsChangedEvent / read anim-speed
// fields via cursor; a home deal-option edit surfacing
// one frame later is invisible (#143).
sync_slide_duration.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_win_cascade.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_achievement_toast,
handle_levelup_toast,
handle_daily_goal_announcement_toast,
@@ -263,16 +298,23 @@ impl Plugin for AnimationPlugin {
handle_weekly_toast,
handle_time_attack_toast,
handle_challenge_toast,
handle_settings_toast,
handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_auto_complete_toast,
handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
(
enqueue_toasts
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
drive_toast_display,
)
.chain(),
adopt_toasts_into_stack,
)
.after(GameMutation),
.chain()
.in_set(AnimFx),
);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! SVG builders for the floating tab bar's action icons.
//!
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
//! is missing on Android entirely), so the icons are authored here as tiny
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
//! as the card themes (`assets::rasterize_svg`).
//!
//! Icons are drawn in **white** on a transparent background inside a
//! 24-unit logical box; the spawn site tints them to the theme colour via
//! `ImageNode::color` (a per-node multiply), so one texture serves every
//! button state.
use bevy::image::Image;
use bevy::math::UVec2;
use super::svg_loader::{SvgLoaderError, rasterize_svg};
/// Rasterisation size for one tab-bar icon texture. Icons display at
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
/// factor (and any future 3× phone) while staying trivially small.
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
/// The five actions the floating touch tab bar can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HudIcon {
/// Counter-clockwise "take back" arrow.
Undo,
/// Two offset card outlines — draw from the stock.
Draw,
/// Lightbulb — highlight a suggested move.
Hint,
/// Two vertical bars.
Pause,
/// Three-line hamburger — the floating menu button.
Menu,
}
impl HudIcon {
/// Build the icon's SVG document. All icons share one visual language:
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
pub fn svg(self) -> String {
let body = match self {
// Arrow head pointing left into a half-circle sweeping back
// over the top — the conventional undo mark.
HudIcon::Undo => concat!(
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
),
// Back card peeks out behind the front card; the front card is
// drawn second so its stroke reads on top.
HudIcon::Draw => concat!(
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
),
// Bulb dome with a flat lamp base beneath.
HudIcon::Hint => concat!(
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
r##"<path d="M 10 21 H 14"/>"##,
),
HudIcon::Pause => concat!(
r##"<path d="M 9 6 V 18"/>"##,
r##"<path d="M 15 6 V 18"/>"##,
),
HudIcon::Menu => concat!(
r##"<path d="M 4 6.5 H 20"/>"##,
r##"<path d="M 4 12 H 20"/>"##,
r##"<path d="M 4 17.5 H 20"/>"##,
),
};
format!(
concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
r##"</svg>"##,
),
body = body
)
}
/// Rasterise this icon into a Bevy [`Image`] at
/// [`HUD_ICON_TEXTURE_PX`] square.
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [HudIcon; 5] = [
HudIcon::Undo,
HudIcon::Draw,
HudIcon::Hint,
HudIcon::Pause,
HudIcon::Menu,
];
/// Every icon must survive the real usvg/resvg pipeline at the real
/// texture size — a malformed path here would otherwise only surface
/// as a blank button on device.
#[test]
fn every_icon_rasterizes_at_texture_size() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
}
}
/// Rasterised icons must contain visible (non-transparent) pixels —
/// guards against a stroke accidentally drawn outside the viewBox.
#[test]
fn every_icon_has_visible_pixels() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
let data = img.data.as_ref().expect("rasterised image has data");
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
assert!(
opaque > 100,
"{icon:?} renders only {opaque} visible pixels"
);
}
}
}
+1
View File
@@ -5,6 +5,7 @@
//! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg;
pub mod hud_icon_svg;
pub mod icon_svg;
pub mod sources;
pub mod svg_loader;
+32 -3
View File
@@ -83,6 +83,14 @@ use tuning::update_input_platform;
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
pub struct CardAnimationPlugin;
/// Set wrapping this plugin's Update chain (curve tweens, hover/drag
/// visuals). First anim stage on the visual spine: …BoardVisuals <
/// MarkerVisuals < CardTweenFx < [`crate::animation_plugin::AnimFx`] <
/// FeedbackFx — members write card `Transform`s, which every other painter
/// set also touches (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct CardTweenFx;
impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) {
// Register events and resources idempotently — double-registration is
@@ -115,7 +123,14 @@ impl Plugin for CardAnimationPlugin {
// still present in the same frame it was removed (deferred
// commands aren't applied until the next ApplyDeferred
// point), causing a 1-frame gap between every chain step.
ApplyDeferred,
//
// `ambiguous_with_all`: an explicit sync point conflicts
// (on `World`) with every unordered system in the
// schedule. Only the flush of THIS chain's commands needs
// ordering — the chain edges provide that; whether an
// unrelated system's commands flush here or at the next
// sync point is immaterial to it (#143).
ApplyDeferred.ambiguous_with_all(),
// After each animation finishes, pop the next chain segment.
advance_animation_chains,
// Interaction visuals (run after animation for final positions).
@@ -123,10 +138,24 @@ impl Plugin for CardAnimationPlugin {
apply_hover_scale,
apply_drag_visual,
// Drain buffered inputs only when no animations remain.
drain_input_buffer,
// It re-emits buffered undo/new-game requests, so it
// joins the self-ambiguous writer clubs (#143).
drain_input_buffer
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
)
.chain()
.after(GameMutation),
.in_set(CardTweenFx),
)
.configure_sets(
Update,
CardTweenFx
.after(GameMutation)
.after(crate::table_plugin::MarkerVisuals)
// HUD text pulses touch Transform on UI entities only —
// disjoint from the card entities this set animates
// (same rationale as UiTextFx × BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
);
}
}
+3 -1
View File
@@ -592,7 +592,9 @@ impl Plugin for CardPlugin {
tick_right_click_highlights,
clear_right_click_highlights_on_state_change,
clear_right_click_highlights_on_pause,
tick_hint_highlight,
// Reads only the anim-speed/reduce-motion flags; a frame
// of lag is invisible (#143).
tick_hint_highlight.ambiguous_with(crate::settings_plugin::SettingsAccess),
update_stock_empty_indicator,
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
collect_resize_events,
+23 -4
View File
@@ -11,8 +11,7 @@ use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
/// Minimum player level required to start a Challenge run.
@@ -38,8 +37,28 @@ impl Plugin for ChallengePlugin {
.add_message::<StartChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
// Ordering comes from the mode-completion chain configured in
// progress_plugin; the toast it emits joins the writer club
// drained by the toast pipeline this frame (#143).
.add_systems(
Update,
advance_on_challenge_win
.in_set(crate::progress_plugin::ChallengeCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_challenge_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
);
}
}
+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::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
@@ -115,6 +115,9 @@ impl Plugin for CoreGamePlugin {
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin)
.add_plugins(CheatSheetPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
+22 -3
View File
@@ -76,12 +76,31 @@ pub struct CursorPlugin;
impl Plugin for CursorPlugin {
fn build(&self, app: &mut App) {
// All three read the DragState/game state the input chain and
// mutation systems settle, so they run after both. The highlight
// painters write card/overlay Sprites and join the visual spine
// ahead of the board painters; the cursor icon touches only the
// window cursor (#143).
app.add_systems(
Update,
(
update_cursor_icon,
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
update_cursor_icon
.after(crate::input_plugin::PointerInput)
.after(crate::game_plugin::GameMutation)
.after(crate::selection_plugin::SelectionFx)
.before(crate::card_plugin::BoardVisuals),
(
update_drop_highlights.run_if(resource_changed::<DragState>),
update_drop_target_overlays,
)
.chain()
.after(crate::input_plugin::PointerInput)
.after(crate::game_plugin::GameMutation)
// Selection maintenance clears DragState and the hint
// poll writes highlight sprites — both settle first.
.after(crate::selection_plugin::SelectionFx)
.after(crate::pending_hint::PendingHint)
.before(crate::card_plugin::BoardVisuals),
),
);
}
+43 -9
View File
@@ -24,8 +24,8 @@ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
WarningToastEvent, XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource;
@@ -124,17 +124,51 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
// record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover);
// Ordering comes from the mode-completion chain configured in
// progress_plugin; the toast it emits joins the writer club
// drained by the toast pipeline this frame (#143).
.add_systems(
Update,
handle_daily_completion
.in_set(crate::progress_plugin::DailyCompletion)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_daily_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// One chain for the DailyChallengeResource writers/readers:
// the local date rollover replaces today's challenge and the
// expiry warning reads it. Before ModeStart so a rollover
// lands before the start handler reads the seed this frame
// (#143).
.add_systems(
Update,
(
check_date_rollover,
check_daily_expiry_warning
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
)
.chain()
.before(crate::game_plugin::ModeStart),
);
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
// The poll replaces DailyChallengeResource, so it heads the same
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge);
.add_systems(Update, poll_server_challenge.before(check_date_rollover));
}
}
+10 -2
View File
@@ -21,7 +21,6 @@ use solitaire_core::game_state::{DifficultyLevel, GameMode};
use solitaire_data::difficulty_seeds::seeds_for;
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
use crate::game_plugin::GameMutation;
// ---------------------------------------------------------------------------
// Resources
@@ -77,7 +76,16 @@ impl Plugin for DifficultyPlugin {
app.init_resource::<DifficultyIndexResource>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(Update, handle_difficulty_request.before(GameMutation));
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request stream with the
// other writers (#143).
.add_systems(
Update,
handle_difficulty_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
);
}
}
+54 -7
View File
@@ -198,6 +198,22 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
/// animation systems.
pub struct FeedbackAnimPlugin;
/// Set wrapping the shake/settle/deal/flourish systems. Last anim stage on
/// the visual spine: …MarkerVisuals < CardTweenFx <
/// [`crate::animation_plugin::AnimFx`] < FeedbackFx. Runs after
/// MarkerVisuals because the foundation flourish tints pile-marker sprites,
/// which the marker painters also write; the tint must win within the frame
/// (#143). GameMutation ordering is inherited transitively.
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct FeedbackFx;
/// Set wrapping the hint-ghost chain (spawn → tick → despawn). Ghost
/// entities are `Without<CardEntity>` sprites owned solely by this chain —
/// systems that animate other entity domains (e.g. the win-summary camera
/// shake) declare themselves `.ambiguous_with(HintGhostFx)` (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct HintGhostFx;
impl Plugin for FeedbackAnimPlugin {
fn build(&self, app: &mut App) {
// Register the events this plugin consumes so it can run in isolation
@@ -211,22 +227,36 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>()
.configure_sets(
Update,
FeedbackFx
.after(GameMutation)
.after(crate::animation_plugin::AnimFx)
// HUD text pulses are disjoint from the card/marker
// entities this set animates (see UiTextFx ×
// BoardVisuals, #143).
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.add_systems(
Update,
(
start_shake_anim.after(GameMutation),
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
start_shake_anim.ambiguous_with(crate::settings_plugin::SettingsAccess),
tick_shake_anim,
start_settle_anim.after(GameMutation),
start_settle_anim,
// tick_foundation_flourish writes the full Transform.scale
// (Vec3); tick_settle_anim writes only scale.y on top of
// it. Ordering ensures the settle's y-only write always
// applies last so it wins on the ~0.15 s overlap when both
// components are present on the same King entity.
tick_foundation_flourish.before(tick_settle_anim),
tick_foundation_flourish,
tick_settle_anim,
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
),
start_deal_anim,
start_foundation_flourish,
)
.chain()
.in_set(FeedbackFx),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
@@ -240,7 +270,24 @@ impl Plugin for FeedbackAnimPlugin {
tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation),
)
.chain(),
.chain()
.in_set(HintGhostFx)
// The spawn reads card Sprites the marker painters (theme
// apply, pile highlights) also write — those settle first.
// Ghost entities are Without<CardEntity> and never HUD
// chrome, and the spawn reads only the reduce-motion flag
// (frame-lag invisible) (#143).
.after(crate::table_plugin::MarkerVisuals)
// …and the card tweens/slides, whose Transform writes
// the spawn snapshot must not race.
.after(crate::card_animation::CardTweenFx)
// FeedbackFx (deal slide, shake) is the last Transform
// writer on the spine — the ghost snapshot reads after
// the whole visual pipeline has settled.
.after(FeedbackFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
);
}
}
+51 -1
View File
@@ -99,6 +99,38 @@ pub struct DrawRequestWriters;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct InfoToastWriters;
/// Self-ambiguous club for writers of `MoveRequestEvent` (drag drops, stock
/// clicks, radial menu, selection keys, replay step button). Consumers drain
/// the whole queue — append order is meaningless (#143). Same rules as
/// [`NewGameRequestWriters`]: do NOT hang ordering edges off this set.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoveRequestWriters;
/// Self-ambiguous club for writers of `WarningToastEvent` (daily-expiry
/// warning, leaderboard opt-in/out failure polls). The toast pipeline
/// drains the whole queue — append order is meaningless (#143). Same
/// rules as [`InfoToastWriters`].
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WarningToastWriters;
/// Set + self-ambiguous club for the mode-start request handlers (challenge,
/// daily, time-attack, play-by-seed, difficulty). Each drains its own
/// request stream and resets `GameStateResource` for its mode; only one
/// stream carries an event on any real frame, so member order is irrelevant
/// (club), while the set as a whole runs between the stats abandon-recorder
/// ([`crate::stats_plugin::AbandonRecord`], which must read the old game
/// first) and [`GameMutation`] (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModeStart;
/// Set wrapping `GamePlugin`'s pre-mutation game-state spine (elapsed-time
/// tick, settings sync, restore prompt). Sits between
/// [`crate::settings_plugin::SettingsMutation`] and [`GameMutation`]; other
/// pre-mutation readers of `GameStateResource` (e.g. the stats
/// abandon-recorder) order themselves `.after(PreMutation)` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PreMutation;
/// Persistence path for the in-progress game state file. `None` disables I/O.
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);
@@ -267,7 +299,14 @@ impl Plugin for GamePlugin {
.in_set(InfoToastWriters)
.ambiguous_with(InfoToastWriters),
)
.add_systems(Update, record_replay_on_win.after(GameMutation))
.add_systems(
Update,
record_replay_on_win
.after(GameMutation)
// Before the playback skip-recorder (which runs after
// AutoComplete) — both touch RecordingReplay (#143).
.before(crate::auto_complete_plugin::AutoComplete),
)
.add_systems(
Update,
(
@@ -301,9 +340,20 @@ impl Plugin for GamePlugin {
.ambiguous_with(NewGameRequestWriters),
)
.chain()
.in_set(PreMutation),
)
.configure_sets(
Update,
PreMutation
.after(crate::settings_plugin::SettingsMutation)
.before(GameMutation),
)
.configure_sets(
Update,
ModeStart
.after(crate::stats_plugin::AbandonRecord)
.before(GameMutation),
)
.init_resource::<AutoSaveTimer>()
.add_systems(Update, auto_save_game_state.after(GameMutation))
.add_systems(Last, save_game_state_on_exit);
+17 -6
View File
@@ -54,12 +54,23 @@ impl Plugin for HelpPlugin {
.add_message::<TouchInput>()
.add_systems(
Update,
(
toggle_help_screen,
handle_help_close_button,
scroll_help_panel,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
),
(toggle_help_screen, handle_help_close_button)
.chain()
// FocusKeys consumes Esc after the modal handlers
// (mirrors toggle_settings_screen, #143).
.before(crate::ui_focus::FocusKeys),
)
.add_systems(
Update,
scroll_help_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
}
}
+39 -7
View File
@@ -334,19 +334,45 @@ impl Plugin for HomePlugin {
spawn_home_on_launch,
toggle_home_screen,
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_new_game_hero,
handle_home_card_click
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_home_new_game_hero
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_home_cancel_button,
handle_home_profile_chip,
handle_home_profile_chip
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
handle_home_draw_mode_buttons,
handle_home_winnable_toggle,
handle_home_deal_options_toggle,
handle_home_difficulty_chip_click,
handle_home_digit_keys,
handle_home_difficulty_chip_click
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_home_digit_keys
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.before(crate::ui_focus::FocusKeys),
)
.chain(),
.chain()
// The home context repaints from this frame's settled
// progress/stats/achievements; settings access joins the
// screen-plugin club (#143).
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::stats_plugin::StatsUpdate)
.after(crate::achievement_plugin::AchievementEval)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.add_systems(Update, scroll_home_panel);
.add_systems(
Update,
scroll_home_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
}
}
@@ -480,6 +506,10 @@ fn persist_last_mode(
/// 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
/// pressed `M` between ticks).
/// * `LaunchHomeShown` flips to `true` after the first spawn so this
@@ -494,6 +524,7 @@ fn spawn_home_on_launch(
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
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>>,
sources: HomeSpawnSources,
mut deal_expanded: ResMut<DealOptionsExpanded>,
@@ -503,6 +534,7 @@ fn spawn_home_on_launch(
|| !restore_prompts.is_empty()
|| 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()
+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);
}
}
}
+9 -2
View File
@@ -109,12 +109,19 @@ pub(super) fn apply_action_fade(
/// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
/// their own per-button colour triple and are painted by
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
/// fills would destroy the translucent glass look).
#[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
(With<ActionButton>, Changed<Interaction>),
(
With<ActionButton>,
Without<GlassTabButton>,
Changed<Interaction>,
),
>,
) {
for (interaction, mut bg) in &mut buttons {
+71 -8
View File
@@ -12,11 +12,13 @@ use bevy::window::WindowResized;
mod fx;
mod interaction;
mod spawn;
pub(crate) mod tab_bar;
mod updates;
pub use fx::*;
use interaction::*;
use spawn::*;
use tab_bar::*;
use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
@@ -515,13 +517,31 @@ impl Plugin for HudPlugin {
Update,
(restore_hud_on_modal, apply_hud_visibility)
.chain()
.before(LayoutSystem::UpdateOnResize),
.before(LayoutSystem::UpdateOnResize)
// Toggles Visibility on HUD chrome roots only — joins
// the chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
.add_systems(
Update,
(
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
handle_avatar_button.ambiguous_with(HudButtons),
update_hud_avatar
// Same-frame-exact after the settings spine, so NOT
// a SettingsAccess member — but the club's readers
// and the home deal-option writers touch settings
// fields the avatar never reads (#143).
.ambiguous_with(crate::settings_plugin::SettingsAccess)
.after(crate::settings_plugin::SettingsMutation)
// Two more settings writers arrived with the wider
// ambiguity cluster: the pause overlay's draw-mode
// toggle and the achievement onboarding flag (#143).
.after(crate::pause_plugin::PauseMutation)
.after(crate::game_plugin::InfoToastWriters),
handle_avatar_button
.ambiguous_with(HudButtons)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
),
)
.add_systems(
@@ -536,7 +556,11 @@ impl Plugin for HudPlugin {
// the board painters that resize card/label text (#143).
.add_systems(
Update,
update_hud_typography.after(crate::card_plugin::BoardVisuals),
update_hud_typography
.after(crate::card_plugin::BoardVisuals)
// Writes HUD TextFont only — chrome club (#143).
.in_set(UiTextFx)
.ambiguous_with(UiTextFx),
)
.add_systems(
Update,
@@ -549,16 +573,26 @@ impl Plugin for HudPlugin {
.after(GameMutation)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
.ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.add_systems(
Update,
(start_streak_flourish, advance_streak_flourish)
.chain()
.after(GameMutation)
// Reads the streak-milestone events StatsUpdate emits.
.after(crate::stats_plugin::StatsUpdate)
.in_set(UiTextFx)
.ambiguous_with(UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals),
.ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only the reduce-motion flag; a frame of lag is
// invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.add_systems(
Update,
@@ -582,19 +616,48 @@ impl Plugin for HudPlugin {
handle_help_button,
handle_hint_button
.after(GameMutation)
// A press cancels/spawns the solver task before
// this frame's drop/poll pass (#143).
.before(crate::pending_hint::PendingHint)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_modes_button,
// The modes/menu popovers repaint the player level from
// ProgressResource — read it after this frame's XP award
// settles (#143).
handle_modes_button
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval),
handle_mode_option_click
.in_set(NewGameRequestWriters)
.ambiguous_with(NewGameRequestWriters),
handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button,
handle_menu_option_click,
handle_menu_option_click
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.in_set(crate::profile_plugin::ProfileToggleWriters)
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
paint_glass_tab_buttons,
drive_tab_expansion,
// Writes tab-label TextColor and reads TextFont — entity-
// disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion
.ambiguous_with(UiTextFx)
// Tab-label fonts are never card/corner-label fonts.
.ambiguous_with(crate::card_plugin::BoardVisuals)
// Reads only reduce-motion; a frame of lag is
// invisible, so it joins the settings-reader club.
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
// Reads only reduce-motion — settings-reader club (#143).
duck_tab_bar_during_drag
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.chain()
.in_set(HudButtons)
+16 -104
View File
@@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button(
pub(super) fn spawn_action_buttons(
font_res: Option<Res<FontResource>>,
windows: Query<&Window>,
images: Option<ResMut<Assets<Image>>>,
mut commands: Commands,
) {
let action_font_size =
@@ -370,9 +371,18 @@ pub(super) fn spawn_action_buttons(
..default()
};
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// Touch layout: the floating glass tab bar (pill + circular Menu).
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
// returning `None` switches the bar to its text-fallback form.
if USE_TOUCH_UI_LAYOUT {
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
return;
}
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// Android reports it (frames 1-3); initial value is 0.0.
// the platform reports it; initial value is 0.0.
commands
.spawn((
Node {
@@ -400,73 +410,10 @@ pub(super) fn spawn_action_buttons(
HudActionBar,
))
.with_children(|row| {
if USE_TOUCH_UI_LAYOUT {
spawn_touch_action_bar(row, &font);
} else {
spawn_desktop_action_bar(row, &font);
}
spawn_desktop_action_bar(row, &font);
});
}
/// Phase F touch action bar — five buttons, three of them big.
///
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
/// utility actions the desktop bar carries are reachable elsewhere on
/// touch and are deliberately absent here: Help lives in Menu → System,
/// mode switching lives on the Home screen (Phase B), and New Game is
/// Home's hero button. Draw duplicates the stock-pile tap so the most
/// frequent action of all no longer requires reaching the top half of a
/// tall folded screen.
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab (external keyboard) cycles the bar in visual reading order.
spawn_action_button(
row,
MenuButton,
"Menu",
None,
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
font,
0,
TEXT_SECONDARY,
);
spawn_primary_action_button(
row,
UndoButton,
"Undo",
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
1,
);
spawn_primary_action_button(
row,
DrawButton,
"Draw",
"Draw from the stock — same as tapping the deck.",
font,
2,
);
spawn_primary_action_button(
row,
HintButton,
TOUCH_HINT_LABEL,
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
3,
);
spawn_action_button(
row,
PauseButton,
"Pause",
None,
"Pause the game and freeze the timer.",
font,
4,
TEXT_SECONDARY,
);
}
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
/// is touch-only). All seven actions, uniform sizing.
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
@@ -585,44 +532,9 @@ pub(super) fn spawn_action_button<M: Component>(
);
}
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
/// and Hint read (and hit) as the primary actions. Hotkey chips are
/// irrelevant on touch, so the variant takes none.
pub(super) fn spawn_primary_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
tooltip: &'static str,
font: &TextFont,
order: i32,
) {
let (pad, min_w, min_h) = primary_action_button_metrics();
let primary_font = TextFont {
font: font.font.clone(),
// 1.35× the bar's responsive size, capped so landscape tablets
// don't blow the row height out.
font_size: (font.font_size * 1.35).min(24.0),
..default()
};
spawn_action_button_sized(
row,
marker,
label,
None,
tooltip,
&primary_font,
order,
TEXT_PRIMARY,
pad,
min_w,
min_h,
);
}
/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
/// — one place owns the component set so `paint_action_buttons`, tooltips,
/// and the focus ring treat every bar button identically.
/// Shared body of [`spawn_action_button`] — one place owns the component
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
/// bar button identically.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button_sized<M: Component>(
row: &mut ChildSpawnerCommands,
+529
View File
@@ -0,0 +1,529 @@
//! Floating "liquid glass" touch tab bar — layout and animation.
//!
//! Replaces the docked full-width touch action bar (Phase F) with an
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
//! above the bottom edge, plus a detached circular Menu button to its
//! right. The *visual treatment* (translucent sheen, specular rim, drop
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
//! button wiring, and the expand/collapse label animation, so the glass
//! material can be swapped without touching layout.
//!
//! Interaction compatibility: every button keeps the same marker
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
//! `interaction.rs` all work unchanged.
//!
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
//! frequent action — is the persistently-expanded "active" pill showing
//! icon + label; the other buttons are icon-only and reveal their label
//! with a width-animated slide while pressed.
use super::*;
use crate::assets::hud_icon_svg::HudIcon;
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
use bevy::text::{LineBreak, TextLayout};
use bevy::ui::{Overflow, UiTransform, Val2};
use crate::resources::DragState;
use crate::safe_area::SafeAreaInsets;
/// Height of both the pill and the circular Menu button, in logical px.
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
/// floor with 8 px of glass around them.
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
/// Gap between the bar and the bottom screen edge (the "floating" margin;
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
const TAB_BAR_MARGIN: f32 = SPACE_3;
/// Vertical space UI stacked above the bar (the toast stack) must clear:
/// floating margin + bar height + one spacing step of breathing room.
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
/// Diameter of a button inside the pill (and its fully-round radius ×2).
const TAB_BUTTON_SIZE: f32 = 48.0;
/// Displayed icon size in logical px (texture is rasterised larger; see
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
const TAB_ICON_PX: f32 = 24.0;
/// FiraMono glyph advance as a fraction of font size. The bar is
/// monospace-only, so `chars × advance × font_size` is the exact label
/// width — the same ratio `action_bar_font_size` documents.
const FIRA_ADVANCE: f32 = 0.62;
/// Gap between an icon and its revealed label, folded into the animated
/// wrapper width so it collapses together with the text.
const LABEL_LEAD_PX: f32 = SPACE_2;
/// Idle fill of an inactive icon button — fully transparent so the pill's
/// glass shows through untouched.
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Pressed fill of an inactive icon button.
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
/// Pressed state of the active (accent-filled) pill — darker than idle so
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
/// component also routes the button to `paint_glass_tab_buttons` instead of
/// the legacy `paint_action_buttons` (which excludes it).
#[derive(Component, Debug)]
pub struct GlassTabButton {
idle: Color,
hovered: Color,
pressed: Color,
}
/// Animated label-reveal state, on the clipping wrapper node between the
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
/// via [`expansion_width`]; `target` is where it is heading.
#[derive(Component, Debug)]
pub struct TabLabelWrap {
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
pub progress: f32,
/// Reveal amount the animation is moving towards.
pub target: f32,
/// `true` keeps the label expanded regardless of interaction —
/// the persistent Draw pill, and every button in text-fallback mode.
persistent: bool,
/// Label length; width is recomputed from the live font size each
/// frame so viewport-driven font rescales stay correct.
label_chars: usize,
/// Last width written to the node, to skip no-op layout writes.
last_width: f32,
}
/// Marker on the label `Text` inside a [`TabLabelWrap`].
#[derive(Component, Debug)]
pub struct TabLabel;
/// Duck-away state on the bar container: while a card drag is committed
/// the whole bar slides below the screen edge so it never occludes a
/// drop target on the bottom tableau rows, then slides back on release.
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
#[derive(Component, Debug)]
pub struct TabBarDuck {
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
pub progress: f32,
}
/// Extra slide distance past the bar's own height so its drop shadow
/// (16 px blur + 6 px offset) also clears the screen edge.
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
/// Rasterised icon textures for the five tab-bar actions.
pub(super) struct TabIcons {
undo: Handle<Image>,
draw: Handle<Image>,
hint: Handle<Image>,
pause: Handle<Image>,
menu: Handle<Image>,
}
impl TabIcons {
/// Rasterise all five icons into `images`. Returns `None` (and warns)
/// if any icon fails — the caller then falls back to text-only labels,
/// so a bad SVG degrades the look, never the playability.
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
let mut add = |icon: HudIcon| match icon.rasterize() {
Ok(img) => Some(images.add(img)),
Err(e) => {
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
None
}
};
Some(Self {
undo: add(HudIcon::Undo)?,
draw: add(HudIcon::Draw)?,
hint: add(HudIcon::Hint)?,
pause: add(HudIcon::Pause)?,
menu: add(HudIcon::Menu)?,
})
}
}
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
/// `font_size`, revealed to `progress`. Pure so the animation curve is
/// unit-testable without an app.
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
}
/// Advance `progress` towards `target` by one frame of `dt` seconds.
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
/// active. Pure for the same reason as [`expansion_width`].
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
if reduce_motion {
return target;
}
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
progress + (target - progress).clamp(-max_step, max_step)
}
/// Spawns the floating glass tab bar: one full-width invisible flex row
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
/// centring the glass pill and the detached circular Menu button.
///
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
/// an SVG regression) switches every button to text-fallback: labels stay
/// expanded so the bar remains fully usable without textures.
pub(super) fn spawn_glass_tab_bar(
commands: &mut Commands,
font: &TextFont,
icons: Option<&TabIcons>,
) {
let text_fallback = icons.is_none();
commands
.spawn((
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TAB_BAR_MARGIN),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredBottom {
base_bottom: TAB_BAR_MARGIN,
},
HudActionBar,
TabBarDuck { progress: 0.0 },
UiTransform::IDENTITY,
))
.with_children(|bar| {
// The main pill: Undo · [Draw] · Hint · Pause.
bar.spawn((
Node {
height: Val::Px(TAB_BAR_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
column_gap: Val::Px(4.0),
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
glass_surface(),
))
.with_children(|pill| {
spawn_tab_button(
pill,
UndoButton,
"Undo",
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
0,
icons.map(|i| i.undo.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
DrawButton,
"Draw",
"Draw from the stock — same as tapping the deck.",
font,
1,
icons.map(|i| i.draw.clone()),
true,
text_fallback,
);
spawn_tab_button(
pill,
HintButton,
TOUCH_HINT_LABEL,
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
2,
icons.map(|i| i.hint.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
PauseButton,
"Pause",
"Pause the game and freeze the timer.",
font,
3,
icons.map(|i| i.pause.clone()),
false,
text_fallback,
);
});
// The detached circular Menu button, same glass material.
bar.spawn((
MenuButton,
ActionButton,
Button,
GlassTabButton {
idle: GLASS_BTN_IDLE,
hovered: GLASS_BTN_HOVER,
pressed: GLASS_BTN_PRESSED,
},
Tooltip::new(
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
),
Focusable {
group: FocusGroup::Hud,
order: 4,
},
Node {
width: Val::Px(TAB_BAR_HEIGHT),
height: Val::Px(TAB_BAR_HEIGHT),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
BackgroundColor(GLASS_BTN_IDLE),
glass_surface(),
))
.with_children(|circle| {
if let Some(icons) = icons {
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
} else {
// Text fallback: a menu glyph-free label keeps the
// button discoverable without any texture.
circle.spawn((
Text::new("Menu"),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(TEXT_PRIMARY),
));
}
});
});
}
/// Spawns one button inside the pill: icon, then the width-animated label
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
/// `text_fallback` pins every label open when icons are unavailable.
#[allow(clippy::too_many_arguments)]
fn spawn_tab_button<M: Component>(
pill: &mut ChildSpawnerCommands,
marker: M,
label: &str,
tooltip: &'static str,
font: &TextFont,
order: i32,
icon: Option<Handle<Image>>,
active: bool,
text_fallback: bool,
) {
let (idle, hovered, pressed) = if active {
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
} else {
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
};
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
let persistent = active || text_fallback;
let start = if persistent { 1.0 } else { 0.0 };
let chars = label.chars().count();
pill.spawn((
marker,
ActionButton,
Button,
GlassTabButton {
idle,
hovered,
pressed,
},
Tooltip::new(tooltip),
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
height: Val::Px(TAB_BUTTON_SIZE),
min_width: Val::Px(TAB_BUTTON_SIZE),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
..default()
},
BackgroundColor(idle),
))
.with_children(|b| {
if let Some(handle) = icon {
spawn_tab_icon(b, handle, icon_tint);
}
// The clipping wrapper the animation drives. Label text sits
// left-anchored inside, so growing the width reveals it
// left-to-right and shrinking clips it from the right.
b.spawn((
TabLabelWrap {
progress: start,
target: start,
persistent,
label_chars: chars,
last_width: f32::NAN,
},
Node {
overflow: Overflow::clip(),
width: Val::Px(expansion_width(chars, font.font_size, start)),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::FlexEnd,
..default()
},
))
.with_children(|wrap| {
let mut color = TEXT_PRIMARY;
color.set_alpha(start);
wrap.spawn((
TabLabel,
ActionButtonLabel,
Text::new(label),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(color),
));
});
});
}
/// One fixed-size tinted icon node.
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
parent.spawn((
ImageNode {
color: tint,
..ImageNode::new(handle)
},
Node {
width: Val::Px(TAB_ICON_PX),
height: Val::Px(TAB_ICON_PX),
flex_shrink: 0.0,
..default()
},
));
}
/// Sets each label wrapper's animation target from its button's
/// interaction state: persistent wrappers pin to 1, the rest expand while
/// pressed or hovered and collapse on release.
pub(super) fn drive_tab_expansion(
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
mut wraps: Query<&mut TabLabelWrap>,
) {
for (interaction, children) in &buttons {
for child in children.iter() {
let Ok(mut wrap) = wraps.get_mut(child) else {
continue;
};
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
1.0
} else {
0.0
};
// Bypass change detection on no-ops so `animate_tab_expansion`
// and layout stay idle while nothing moves.
if wrap.target != target {
wrap.target = target;
}
}
}
}
/// Advances every label reveal towards its target and writes the wrapper
/// width + label alpha. Width is derived from the label's *live* font size,
/// so the Fold's posture-change font rescale re-widens expanded labels
/// automatically. Node writes are skipped when the width is unchanged to
/// avoid dirtying UI layout every frame.
pub(super) fn animate_tab_expansion(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
for (mut wrap, mut node, children) in &mut wraps {
for child in children.iter() {
let Ok((font, mut text_color)) = labels.get_mut(child) else {
continue;
};
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
let width = expansion_width(wrap.label_chars, font.font_size, next);
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
// first frame always writes, and equal floats mean a true no-op.
if next != wrap.progress || width != wrap.last_width {
wrap.progress = next;
wrap.last_width = width;
node.width = Val::Px(width);
text_color.0.set_alpha(next);
}
break;
}
}
}
/// Slides the bar off the bottom edge while a card drag is committed and
/// back once it ends. Effectively touch-only: the desktop docked bar
/// never carries [`TabBarDuck`], so the query is empty there.
///
/// The slide animates `UiTransform::translation` (a render-time offset,
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
/// never dirtied mid-drag (the whole point is not to cost frames while
/// the player is dragging), and `Node::bottom` is owned by the safe-area
/// anchor system — two writers on one field would fight.
pub(super) fn duck_tab_bar_during_drag(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
drag: Option<Res<DragState>>,
insets: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
) {
let target = if drag.as_deref().is_some_and(|d| d.committed) {
1.0
} else {
0.0
};
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
// Physical-px inset → logical, same conversion the safe-area anchors
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
// so the slide must cover that extra travel to fully clear the edge.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
for (mut duck, mut transform) in &mut bars {
let next = step_expansion(duck.progress, target, dt, reduce_motion);
if next == duck.progress && duck.progress == target {
continue;
}
duck.progress = next;
transform.translation = Val2::px(0.0, next * slide_px);
}
}
/// Idle / hover / pressed feedback for glass tab buttons — the glass
/// counterpart of `paint_action_buttons`, using each button's own colour
/// triple so the accent-filled active pill and the transparent icon
/// buttons share one system.
pub(super) fn paint_glass_tab_buttons(
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
) {
for (interaction, style, mut bg) in &mut buttons {
bg.0 = match interaction {
Interaction::Pressed => style.pressed,
Interaction::Hovered => style.hovered,
Interaction::None => style.idle,
};
}
}
+135 -7
View File
@@ -907,6 +907,10 @@ fn hud_focus_only_engages_when_button_hovered() {
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
/// buttons the tested spawn function creates — `headless_app` would
/// pre-spawn the platform-default bar and pollute the counts.
///
/// The touch branch spawns the glass tab bar with `icons: None`
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
/// text-fallback path — same markers, labels pinned expanded.
fn bar_only_app(touch: bool) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -914,13 +918,13 @@ fn bar_only_app(touch: bool) -> App {
let font = TextFont::default();
let world = app.world_mut();
let mut commands = world.commands();
commands.spawn(Node::default()).with_children(|row| {
if touch {
spawn_touch_action_bar(row, &font);
} else {
if touch {
spawn_glass_tab_bar(&mut commands, &font, None);
} else {
commands.spawn(Node::default()).with_children(|row| {
spawn_desktop_action_bar(row, &font);
}
});
});
}
app.update();
app
}
@@ -933,7 +937,7 @@ fn count_buttons<C: Component>(app: &mut App) -> usize {
}
#[test]
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
let mut app = bar_only_app(true);
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
@@ -949,6 +953,130 @@ fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
}
/// The glass bar floats: its container must carry a bottom margin (not be
/// docked at 0) both before and after safe-area insets arrive.
#[test]
fn glass_tab_bar_floats_above_bottom_edge() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
let (node, anchor) = q.single(world).expect("glass bar container exists");
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
}
/// Draw is the persistently-expanded active pill (user decision
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
/// text fallback, so instead assert on the *persistent* flag's effect:
/// all wrappers spawn at full reveal and target full reveal.
#[test]
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query::<&TabLabelWrap>();
let wraps: Vec<_> = q.iter(world).collect();
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
for wrap in wraps {
assert_eq!(wrap.progress, 1.0);
assert_eq!(wrap.target, 1.0);
}
}
/// Focus order must match visual reading order: pill buttons left to
/// right, then the detached Menu circle.
#[test]
fn glass_tab_bar_focus_order_reads_left_to_right() {
let mut app = bar_only_app(true);
for (f, expected) in [
(focusable_for::<UndoButton>(&mut app), 0),
(focusable_for::<DrawButton>(&mut app), 1),
(focusable_for::<HintButton>(&mut app), 2),
(focusable_for::<PauseButton>(&mut app), 3),
(focusable_for::<MenuButton>(&mut app), 4),
] {
assert_eq!(f.group, FocusGroup::Hud);
assert_eq!(f.order, expected);
}
}
/// `expansion_width` is the single source of truth for the label reveal:
/// zero when collapsed, lead + monospace advance when expanded, linear
/// in between.
#[test]
fn expansion_width_is_linear_in_progress() {
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
let full = expansion_width(4, 16.0, 1.0);
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
let half = expansion_width(4, 16.0, 0.5);
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
// Out-of-range progress clamps rather than extrapolating.
assert_eq!(expansion_width(4, 16.0, 1.5), full);
}
/// While a committed drag is active the bar must slide down (positive
/// `UiTransform` y — off-screen) and return to identity once the drag
/// ends. Manual time steps make the tween deterministic.
#[test]
fn tab_bar_ducks_during_committed_drag_and_returns() {
use crate::resources::DragState;
use bevy::ui::UiTransform;
let mut app = bar_only_app(true);
set_manual_time_step(&mut app, 0.05);
app.add_systems(Update, duck_tab_bar_during_drag);
app.insert_resource(DragState {
committed: true,
..default()
});
let translation_y_px = |app: &mut App| -> f32 {
let world = app.world_mut();
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
let t = q.single(world).expect("bar carries UiTransform");
match t.translation.y {
Val::Px(px) => px,
other => panic!("duck must write Val::Px, got {other:?}"),
}
};
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
app.update();
app.update();
let mid = translation_y_px(&mut app);
assert!(mid > 0.0, "bar must start sliding down: {mid}");
for _ in 0..5 {
app.update();
}
let ducked = translation_y_px(&mut app);
assert!(
ducked > TAB_BAR_CLEARANCE_PX,
"fully ducked bar must clear its own floating footprint: {ducked}"
);
// Drag ends → bar slides back to identity.
app.world_mut().resource_mut::<DragState>().committed = false;
for _ in 0..8 {
app.update();
}
assert_eq!(translation_y_px(&mut app), 0.0);
}
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
/// and snap instantly under reduce-motion.
#[test]
fn step_expansion_tweens_and_respects_reduce_motion() {
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
assert!(one_frame > 0.0 && one_frame < 0.2);
// A huge dt clamps at the target instead of overshooting.
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
// Collapse works symmetrically.
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
// Reduce-motion snaps regardless of dt.
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
}
#[test]
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
let mut app = bar_only_app(false);
@@ -573,20 +573,6 @@ pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
}
}
/// Metrics for the touch bar's enlarged Undo / Draw / Hint trio (Phase F).
/// 96×64 px targets — comfortably past the 44 px accessibility floor and
/// big enough to hit one-handed without looking. Only the touch layout
/// spawns primary buttons, so no desktop branch is needed; the desktop
/// values exist purely so host-side tests can exercise the touch spawn
/// path with sensible numbers.
pub(super) fn primary_action_button_metrics() -> (UiRect, Val, Val) {
(
UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
Val::Px(96.0),
Val::Px(64.0),
)
}
pub(super) fn spawn_action_button_label(
parent: &mut ChildSpawnerCommands,
label: &str,
+76 -8
View File
@@ -58,6 +58,14 @@ use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::DrawStockConfig;
/// Set wrapping the core pointer/keyboard input chain (clicks, drags, touch,
/// hotkeys). Runs between [`crate::game_plugin::PreMutation`] (it reads the
/// ticked game state) and [`crate::selection_plugin::SelectionKeySet`] (the
/// selection keys check `DragState::is_idle`); request events it emits are
/// consumed at `GameMutation` in the same frame (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PointerInput;
/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
@@ -143,7 +151,13 @@ impl Plugin for InputPlugin {
// `.chain()` still orders them relative to each other.
handle_keyboard_core
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
// N/Z/X/… and U also feed the shared new-game and
// undo request streams (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
handle_keyboard_hint,
handle_keyboard_forfeit,
handle_stock_click
@@ -159,29 +173,61 @@ impl Plugin for InputPlugin {
end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState).
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
touch_follow_drag,
// Moves the dragged card's Transform; UiTextFx members
// animate chrome entities only — disjoint domains Bevy
// can't prove (#143).
touch_follow_drag.ambiguous_with(crate::ui_theme::UiTextFx),
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag
.after(TouchDragSet::BeforeEndDrag)
.before(GameMutation),
// Reads StateChangedEvent via cursor — a reset one frame
// after the mutation is invisible (the cycle index only
// matters on the next H press), so it rides at the end
// of the input chain instead of chasing every
// StateChanged writer (#143).
// It also drains the new-game request stream via its own
// cursor, so it joins the writer club the same way
// enqueue_toasts does (#143).
reset_hint_cycle_on_state_change
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
)
.chain(),
.chain()
.in_set(PointerInput),
)
.add_systems(Update, reset_hint_cycle_on_state_change);
.configure_sets(
Update,
PointerInput
.after(crate::game_plugin::PreMutation)
.before(crate::selection_plugin::SelectionKeySet)
.before(crate::ui_focus::FocusKeys),
);
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
// It writes only `Window.mode`; no system reads the mode, and the
// geometry change it triggers arrives via WindowResized events on a
// later frame — order against Window readers is provably irrelevant
// (#143).
#[cfg(not(target_os = "android"))]
app.add_systems(Update, handle_fullscreen);
app.add_systems(Update, handle_fullscreen.ambiguous_with_all());
app
// Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced.
// in-flight task before its result can be surfaced. The poll
// emits the hint toast and the pile-highlight event the board
// painters consume this frame (#143).
.add_systems(
Update,
(
crate::pending_hint::drop_pending_hint_on_state_change,
crate::pending_hint::poll_pending_hint_task,
crate::pending_hint::poll_pending_hint_task
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain(),
.chain()
.in_set(crate::pending_hint::PendingHint)
.after(GameMutation)
.before(crate::auto_complete_plugin::AutoComplete),
);
}
}
@@ -215,6 +261,7 @@ fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>,
drag: Res<DragState>,
mut ev: CoreKeyboardMessages<'_>,
mut time_attack: Option<ResMut<TimeAttackResource>>,
selection: Option<Res<SelectionState>>,
@@ -227,6 +274,19 @@ fn handle_keyboard_core(
return;
}
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
// Undo / draw / new-game during an active mouse or touch drag would fire
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
// leaving `DragState` origin indices stale against the mutated state.
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
// already drops that lift cleanly when the state moves.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
// During replay playback (Playing or Completed) all game-input shortcuts
// are suppressed. The replay overlay owns Space (pause/resume) and the
// arrow keys (step). Letting game input through would mutate
@@ -473,11 +533,19 @@ pub fn emit_hint_visuals(
fn handle_keyboard_forfeit(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
mut requests: MessageWriter<ForfeitRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
// mid-drag would mutate the game state under the dragged cards.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
if !keys.just_pressed(KeyCode::KeyG) {
return;
}
+120
View File
@@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() {
"pressing H must spawn an async hint task",
);
}
// -----------------------------------------------------------------------
// Pointer-drag / keyboard mutual exclusion.
//
// A state mutation fired while `follow_drag` is writing the dragged cards'
// transforms every frame would make the card re-sync insert a `CardAnim`
// on the same entities (two systems fighting over `Transform`) and leave
// `DragState`'s origin indices stale. `handle_keyboard_core` and
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
// drops that lift cleanly when the state moves.
// -----------------------------------------------------------------------
fn keyboard_core_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<UndoRequestEvent>();
app.add_message::<NewGameRequestEvent>();
app.add_message::<InfoToastEvent>();
app.add_message::<DrawRequestEvent>();
app.add_message::<StartZenRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_core);
app
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn message_count<M: Message>(app: &App) -> usize {
let messages = app.world().resource::<Messages<M>>();
let mut cursor = messages.get_cursor();
cursor.read(messages).count()
}
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
drag.committed = true;
drag.active_touch_id = active_touch_id;
}
#[test]
fn u_key_fires_undo_when_no_drag_active() {
let mut app = keyboard_core_app();
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"U with an idle DragState must fire UndoRequestEvent",
);
}
#[test]
fn u_key_ignored_during_committed_mouse_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, None); // mouse drag
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
0,
"U during a mouse drag must be swallowed",
);
}
#[test]
fn draw_key_ignored_during_touch_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, Some(7)); // real touch id
press_key(&mut app, KeyCode::KeyD);
app.update();
assert_eq!(
message_count::<DrawRequestEvent>(&app),
0,
"D during a touch drag must be swallowed",
);
}
#[test]
fn u_key_allowed_during_keyboard_lift() {
let mut app = keyboard_core_app();
set_drag(
&mut app,
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
);
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"the keyboard-lift sentinel must not block core shortcuts",
);
}
#[test]
fn g_key_forfeit_ignored_during_mouse_drag() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<ForfeitRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_forfeit);
set_drag(&mut app, None);
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(
message_count::<ForfeitRequestEvent>(&app),
0,
"G during a mouse drag must be swallowed",
);
}
+51 -35
View File
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
Z_PAUSE_DIALOG,
};
// ---------------------------------------------------------------------------
@@ -158,18 +158,38 @@ impl Plugin for LeaderboardPlugin {
poll_leaderboard_fetch,
update_leaderboard_panel,
handle_opt_in_button,
poll_opt_in_task,
// The polls share the warning-toast stream with the
// daily-expiry check (drained wholesale, #143).
poll_opt_in_task
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_opt_out_button,
poll_opt_out_task,
poll_opt_out_task
.in_set(crate::game_plugin::WarningToastWriters)
.ambiguous_with(crate::game_plugin::WarningToastWriters),
handle_set_display_name_button,
handle_display_name_text_input,
handle_display_name_confirm,
handle_display_name_cancel,
update_leaderboard_public_name_label,
// Writes one marker-filtered Text label — disjoint from
// the HUD/stats text painters (UiTextFx club, #143).
update_leaderboard_public_name_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.chain(),
.chain()
// Opt-in state and display name live in
// SettingsResource — the whole chain joins the
// screen-plugin settings club (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.add_systems(Update, scroll_leaderboard_panel);
.add_systems(
Update,
scroll_leaderboard_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
}
}
@@ -536,11 +556,6 @@ fn spawn_leaderboard_screen(
font_size: TYPE_CAPTION,
..default()
};
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_row = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
@@ -647,30 +662,31 @@ fn spawn_leaderboard_screen(
.with_children(|body| {
match data {
LeaderboardResource::Idle => {
body.spawn((
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
spawn_empty_state(
body,
"\u{21BB}",
"Fetching the leaderboard\u{2026}",
None,
font_res,
);
}
LeaderboardResource::Error(_) => {
body.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_empty_state(
body,
"!",
"Couldn't reach the leaderboard.",
Some("Try again later."),
font_res,
);
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
body.spawn((
Text::new("Be the first on the leaderboard."),
font_status.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new("Win a game and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_empty_state(
body,
"\u{2660}",
"Be the first on the leaderboard.",
Some("Win a game and opt in to appear here."),
font_res,
);
}
LeaderboardResource::Loaded(rows) => {
// Column headers
+8
View File
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
pub mod card_animation;
pub mod card_plugin;
pub mod challenge_plugin;
pub mod cheat_sheet_plugin;
pub mod contextual_tips_plugin;
pub mod core_game_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
@@ -26,6 +28,7 @@ pub mod font_plugin;
pub mod game_plugin;
pub mod help_plugin;
pub mod home_plugin;
pub mod hotkeys;
pub mod hud_plugin;
pub mod input_plugin;
pub mod layout;
@@ -60,10 +63,12 @@ pub mod theme_store_plugin;
pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus;
pub mod ui_glass;
pub mod ui_modal;
pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin;
pub mod you_hub_plugin;
@@ -93,6 +98,8 @@ pub use card_plugin::{
pub use challenge_plugin::{
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 cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{
@@ -194,6 +201,7 @@ pub use ui_modal::{
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
};
+38 -80
View File
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8);
// ---------------------------------------------------------------------------
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
// two screens stay in sync without a shared abstraction.
// Slide data — the hotkey slide renders the essential subset of the
// 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
// ---------------------------------------------------------------------------
@@ -153,12 +98,26 @@ pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<OnboardingSlideIndex>()
.add_systems(Update, spawn_if_first_run)
.add_systems(
Update,
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
);
app.init_resource::<OnboardingSlideIndex>().add_systems(
Update,
// One chain: all three touch the slide index and the
// first-run flag in SettingsResource. Club membership
// declares the settings access disjoint from the other
// screen plugins'; the keyboard handler runs before
// FocusKeys like every other Esc consumer (#143).
(
spawn_if_first_run,
handle_onboarding_buttons,
handle_onboarding_keyboard.before(crate::ui_focus::FocusKeys),
)
.chain()
// Before the settings spine: the first-run flag write
// lands before every same-frame settings reader (they
// all sit at or after SettingsMutation transitively).
.before(crate::settings_plugin::SettingsMutation)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
);
}
}
@@ -313,6 +272,10 @@ fn complete_onboarding(
despawn_screen(commands, screens);
if let Some(s) = settings {
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);
}
}
@@ -442,8 +405,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as HelpScreen.
for row in HOTKEYS {
// Vertical list of `key — description` rows, same chip style as
// 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 {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
@@ -822,22 +787,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]
fn hotkey_list_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!(
!row.description.is_empty(),
"hotkey description must not be empty"
);
}
fn hotkey_slide_subset_is_non_empty() {
assert!(
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
"the onboarding slide needs at least one essential hotkey"
);
}
}
+49 -13
View File
@@ -104,6 +104,15 @@ pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
/// to disk, and routes the Forfeit confirm-modal flow.
pub struct PausePlugin;
/// Set wrapping every system that mutates [`PausedResource`] or drives the
/// pause/forfeit modal flow. Runs as one deterministic chain between
/// [`crate::settings_plugin::SettingsMutation`] (the pause overlay's
/// draw-mode toggle writes `SettingsResource`) and
/// [`crate::game_plugin::GameMutation`], so every system ordered after
/// `GameMutation` observes this frame's pause state transitively (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PauseMutation;
impl Plugin for PausePlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — other plugins may register these
@@ -116,28 +125,55 @@ impl Plugin for PausePlugin {
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.init_resource::<PausedResource>()
.configure_sets(
Update,
PauseMutation
.after(crate::settings_plugin::SettingsMutation)
// Before the pre-mutation game-state spine: the elapsed
// timer must observe this frame's pause transition, and
// the draw-mode toggle's settings write must land before
// sync_settings_to_game forwards it.
.before(crate::game_plugin::PreMutation)
.before(crate::game_plugin::GameMutation)
// toggle_pause must see SelectionState *before*
// handle_selection_keys clears it, so it can skip Escape
// when a card is selected. The other members mutate
// PausedResource, which the selection keys read.
.before(SelectionKeySet)
// HUD buttons read PausedResource (e.g. hint is inert
// while paused) — order them after this frame's pause
// transition.
.before(crate::hud_plugin::HudButtons),
)
.add_systems(
Update,
(
// toggle_pause must see SelectionState *before* handle_selection_keys
// clears it, so it can skip Escape when a card is selected.
// It must also run *before* handle_forfeit_keyboard so the
// ForfeitConfirmScreen is still alive when toggle_pause's
// early-return guard checks for it — otherwise an Esc that
// closes the forfeit modal would also open pause in the
// same frame.
// toggle_pause must run *before* handle_forfeit_keyboard
// so the ForfeitConfirmScreen is still alive when
// toggle_pause's early-return guard checks for it —
// otherwise an Esc that closes the forfeit modal would
// also open pause in the same frame. The chain preserves
// that edge and makes the whole pause/forfeit flow
// deterministic (all members share PausedResource and
// the forfeit message streams).
// Reads only motion/display settings fields — disjoint
// from the screen-plugin club's writes (#143).
toggle_pause
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_buttons,
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
auto_resume_on_overlay,
handle_pause_draw_buttons
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
auto_resume_on_overlay,
),
handle_forfeit_confirm_buttons,
)
.chain()
.in_set(PauseMutation),
);
}
}
+7
View File
@@ -39,6 +39,13 @@ use crate::resources::{GameStateResource, HintCycleIndex};
/// emits the hint visuals once the task completes. At most one task
/// is ever in flight: a fresh H press while a previous task is
/// running drops the previous task and queues the new one.
/// Set wrapping the async hint pipeline (state-change drop → poll). The
/// HUD hint button orders `.before` it so a press cancels/spawns before
/// this frame's poll; the drag-highlight painters order `.after` it so
/// the hint sprite writes are settled (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PendingHint;
#[derive(Resource, Default)]
pub struct PendingHintTask {
/// `Some` while the solver is still working on a verdict.
+21 -4
View File
@@ -115,10 +115,27 @@ impl Plugin for PlayBySeedPlugin {
Update,
(
handle_open_dialog,
handle_text_input,
tick_debounce_and_spawn_solver_task,
poll_solver_task,
handle_confirm,
// Writes the marker-filtered seed-dialog Text nodes —
// disjoint from the other text painters (UiTextFx
// club, #143).
handle_text_input
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
tick_debounce_and_spawn_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// Reads deal settings for the solver spawn; a frame
// of lag is invisible (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
poll_solver_task
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
// Shares the new-game request stream with the other
// writers (drained wholesale at GameMutation, #143).
handle_confirm
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
handle_cancel,
)
.chain()
+14 -1
View File
@@ -60,6 +60,14 @@ pub struct DailyCalendarDot {
/// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin;
/// Self-ambiguous club for writers of
/// [`crate::events::ToggleProfileRequestEvent`] (HUD avatar button, home
/// profile chip, HUD menu option). The You-hub reader drains the whole
/// queue, so writer append order is irrelevant; `you_hub_plugin` orders
/// its open/tab handling after the club (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProfileToggleWriters;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
@@ -82,7 +90,12 @@ impl Plugin for ProfilePlugin {
.add_message::<MouseWheel>()
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_profile_panel);
.add_systems(
Update,
scroll_profile_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
}
}
+36 -1
View File
@@ -37,6 +37,21 @@ pub struct LevelUpEvent {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProgressUpdate;
/// Set for `challenge_plugin::advance_on_challenge_win`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ChallengeCompletion;
/// Set for `daily_challenge_plugin::handle_daily_completion`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DailyCompletion;
/// Set for `weekly_goals_plugin::evaluate_weekly_goals`. See the
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct WeeklyGoalsEval;
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
///
@@ -74,7 +89,27 @@ impl Plugin for ProgressPlugin {
.add_message::<GameWonEvent>()
.add_systems(
Update,
award_xp_on_win.after(GameMutation).in_set(ProgressUpdate),
award_xp_on_win
.after(GameMutation)
.in_set(ProgressUpdate)
// Base win XP lands before the mode bonuses so the
// level-up attribution is stable.
.before(ChallengeCompletion),
)
// Mode-completion evaluators, pinned in one explicit chain
// INSIDE ProgressUpdate — every `.after(ProgressUpdate)`
// reader then sees fully-settled progress. Each evaluator
// drains GameWonEvent via its own cursor and writes disjoint
// ProgressResource fields plus XP, but a daily win also counts
// toward weekly goals, so two members can fire on the same
// frame — we pin an order rather than argue the XP/level-up
// event stream is commutative (#143).
.configure_sets(
Update,
(ChallengeCompletion, DailyCompletion, WeeklyGoalsEval)
.chain()
.in_set(ProgressUpdate)
.after(GameMutation),
);
}
}
+18 -3
View File
@@ -185,16 +185,31 @@ impl Plugin for RadialMenuPlugin {
// Tests inject `RadialCursorOverride` themselves; production
// never touches it. We do not `init_resource` here so the
// cursor-from-window path is the default.
// The radial gesture reacts to pointer state the drag pipeline
// settles, and its release handler feeds the shared move
// request stream consumed at GameMutation this frame, so the
// chain sits between PointerInput and GameMutation. The redraw
// reads settings (reduce-motion) where a frame of lag is
// invisible — SettingsAccess club (#143).
.add_systems(
Update,
(
radial_open_on_right_click,
radial_open_on_long_press,
radial_track_cursor,
radial_handle_release_or_cancel,
radial_redraw_overlay,
radial_handle_release_or_cancel
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
radial_redraw_overlay
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
)
.chain(),
.chain()
.after(crate::input_plugin::PointerInput)
// Selection keys also touch DragState (mutual pointer/
// keyboard exclusion) — keys settle first.
.after(crate::selection_plugin::SelectionKeySet)
.before(crate::game_plugin::GameMutation),
);
}
}
+60 -14
View File
@@ -464,25 +464,71 @@ impl Plugin for ReplayOverlayPlugin {
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
update_floating_progress_chip,
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
// Marker-filtered Text/Node repaints — each owns one
// disjoint overlay marker, so they join the UiTextFx
// club alongside the HUD text painters (#143).
update_banner_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_progress_text
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_floating_progress_chip
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_scrub_fill
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_header
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_active_row
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_prev_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_move_log_next_rows
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_foundations
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_mini_tableau_stock_waste
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
update_pause_button_label
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_pause_button,
handle_step_button,
// Step/scrub dispatch into the shared move/draw/undo
// request streams (drained wholesale at GameMutation),
// so they join the writer clubs (#143).
handle_step_button
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_pause_keyboard,
handle_stop_keyboard,
handle_arrow_keyboard,
handle_arrow_keyboard
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_stop_button,
)
.chain(),
.chain()
// After the playback driver so painters render the
// post-tick state; before GameMutation so the step /
// scrub events are consumed this frame. Click handlers
// writing ReplayPlaybackState stay at the end of the
// chain — a press is observed by react_to_state_change
// next frame (state-driven loop, see above) (#143).
.after(crate::replay_playback::ReplayPlayback)
.before(crate::game_plugin::GameMutation),
);
}
}
+44 -8
View File
@@ -550,6 +550,14 @@ fn fire_state_changed_on_completion(
*last_was_completed = now_completed;
}
/// Set wrapping the replay playback driver chain (tick → auto-clear →
/// completion `StateChangedEvent`). The tick feeds `MoveRequestEvent` /
/// `DrawRequestEvent` into this frame's [`GameMutation`], so the set sits
/// on the pre-mutation spine; the overlay UI (`replay_overlay`) orders
/// itself after this set so its painters render the post-tick state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReplayPlayback;
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard.
///
@@ -562,18 +570,46 @@ impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>()
.add_systems(
.configure_sets(
Update,
(
tick_replay_playback,
auto_clear_completed_replay,
fire_state_changed_on_completion,
)
.chain(),
ReplayPlayback
.after(crate::game_plugin::PreMutation)
// Input first: a stop/pause press or a drag-emitted
// request this frame is observed before the tick fires
// the next scripted move (#143).
.after(crate::input_plugin::PointerInput)
.before(GameMutation),
)
.add_systems(
Update,
record_replay_skip_during_playback.after(GameMutation),
(
// The tick shares the move/draw request streams with the
// pointer/keyboard writers (drained wholesale at
// GameMutation) and reads playback speed from settings.
tick_replay_playback
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
auto_clear_completed_replay,
// Chained before GameMutation: explicit order against the
// mutation systems' StateChangedEvent writes; readers
// drain the whole queue after GameMutation.
fire_state_changed_on_completion,
)
.chain()
.in_set(ReplayPlayback),
)
.add_systems(
Update,
record_replay_skip_during_playback
.after(GameMutation)
// After AutoComplete so the stats overlay's watch-replay
// handler (which resets ReplayPlaybackState) and the
// win-path recorder have both settled (#143).
.after(crate::auto_complete_plugin::AutoComplete),
);
}
}
+50 -11
View File
@@ -74,10 +74,28 @@ impl Plugin for SafeAreaInsetsPlugin {
.add_systems(
Update,
(
apply_safe_area_anchors,
apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims,
on_app_resumed,
// All three write Node padding on disjoint chrome roots
// (top anchors / bottom anchors / modal scrims); chained
// for a deterministic pass, and they join the UiTextFx
// chrome club — anchored roots are never the label/
// tooltip/readout entities the other members touch,
// which Bevy can't prove from the queries (#143).
(
apply_safe_area_anchors,
apply_safe_area_bottom_anchors,
apply_safe_area_to_modal_scrims,
)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
// UiScale is written on the settings mutator spine.
.after(crate::settings_plugin::SettingsMutation),
// Emits a synthetic WindowResized on resume only; every
// reader drains the queue via cursor, and a resume
// already spans hundreds of ms, so whether the synthetic
// event lands this frame or next is invisible — same
// argument as handle_fullscreen (#143).
on_app_resumed.ambiguous_with_all(),
),
);
@@ -101,9 +119,10 @@ impl Plugin for SafeAreaInsetsPlugin {
fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
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;
}
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
@@ -119,19 +138,31 @@ fn apply_safe_area_anchors(
);
}
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 {
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
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
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;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
@@ -144,8 +175,12 @@ fn apply_safe_area_bottom_anchors(
);
}
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 {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
}
}
@@ -163,19 +198,23 @@ fn apply_safe_area_bottom_anchors(
fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) {
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;
}
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());
// 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.
let top_logical = (insets.top / scale).min(window_height * 0.25);
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
// Physical insets are pre-divided by the UI scale — see
// `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 {
// Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With
+63 -1
View File
@@ -23,14 +23,43 @@ mod tests {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
use bevy::prelude::*;
use crate::achievement_plugin::AchievementPlugin;
use crate::animation_plugin::AnimationPlugin;
use crate::auto_complete_plugin::AutoCompletePlugin;
use crate::card_animation::CardAnimationPlugin;
use crate::card_plugin::CardPlugin;
use crate::challenge_plugin::ChallengePlugin;
use crate::cursor_plugin::CursorPlugin;
use crate::daily_challenge_plugin::DailyChallengePlugin;
use crate::difficulty_plugin::DifficultyPlugin;
use crate::feedback_anim_plugin::FeedbackAnimPlugin;
use crate::game_plugin::GamePlugin;
use crate::help_plugin::HelpPlugin;
use crate::home_plugin::HomePlugin;
use crate::hud_plugin::HudPlugin;
use crate::input_plugin::InputPlugin;
use crate::leaderboard_plugin::LeaderboardPlugin;
use crate::onboarding_plugin::OnboardingPlugin;
use crate::pause_plugin::PausePlugin;
use crate::play_by_seed_plugin::PlayBySeedPlugin;
use crate::profile_plugin::ProfilePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::radial_menu::RadialMenuPlugin;
use crate::replay_overlay::ReplayOverlayPlugin;
use crate::replay_playback::ReplayPlaybackPlugin;
use crate::safe_area::SafeAreaInsetsPlugin;
use crate::selection_plugin::SelectionPlugin;
use crate::settings_plugin::SettingsPlugin;
use crate::splash_plugin::SplashPlugin;
use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin;
use crate::time_attack_plugin::TimeAttackPlugin;
use crate::touch_selection_plugin::TouchSelectionPlugin;
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
use crate::ui_tooltip::UiTooltipPlugin;
use crate::weekly_goals_plugin::WeeklyGoalsPlugin;
use crate::win_summary_plugin::WinSummaryPlugin;
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
/// same day (#143, PRs #146#149) — this is now a hard gate. If your
@@ -51,7 +80,40 @@ mod tests {
.add_plugins(AutoCompletePlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin::headless());
.add_plugins(SettingsPlugin::headless())
// Batch: game-adjacent plugins (all already exercised headless
// by the engine behaviour tests).
.add_plugins(ProgressPlugin::headless())
.add_plugins(StatsPlugin::headless())
.add_plugins(AchievementPlugin::headless())
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(PausePlugin)
// Batch: screen/overlay plugins.
.add_plugins(HomePlugin::headless())
.add_plugins(WinSummaryPlugin)
.add_plugins(SplashPlugin)
.add_plugins(HelpPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(ProfilePlugin)
.add_plugins(LeaderboardPlugin)
// Batch: mode/replay/input plugins.
.add_plugins(ChallengePlugin)
.add_plugins(DailyChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(PlayBySeedPlugin)
.add_plugins(DifficultyPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(InputPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(CursorPlugin)
.add_plugins(SafeAreaInsetsPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app
}
+36 -7
View File
@@ -134,6 +134,14 @@ impl KeyboardDragState {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionKeySet;
/// Set wrapping the post-mutation selection maintenance chain (clearing the
/// selection on state change, painting the highlight). Runs between
/// [`crate::game_plugin::GameMutation`] and
/// [`crate::card_plugin::BoardVisuals`] so the painters read the settled
/// selection/drag state (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionFx;
/// Marker component placed on the outline sprite used as the keyboard-selection
/// highlight.
///
@@ -158,14 +166,35 @@ impl Plugin for SelectionPlugin {
(
handle_selection_keys
.in_set(SelectionKeySet)
.before(GameMutation),
clear_selection_on_state_change.after(GameMutation),
update_selection_highlight.after(GameMutation).run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<GameStateResource>),
),
.before(GameMutation)
// Enter-to-move feeds the shared move request
// stream (drained wholesale at GameMutation, #143).
.in_set(crate::game_plugin::MoveRequestWriters)
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
(
clear_selection_on_state_change,
update_selection_highlight.run_if(
resource_changed::<SelectionState>
.or(resource_changed::<KeyboardDragState>)
.or(resource_changed::<GameStateResource>),
),
)
.chain()
.in_set(SelectionFx),
),
)
.configure_sets(
Update,
SelectionFx
.after(GameMutation)
.before(crate::card_plugin::BoardVisuals),
)
.configure_sets(
Update,
// Selection reads GameStateResource (legal destinations) —
// run after the pre-mutation spine that ticks/mutates it
// (#143).
SelectionKeySet.after(crate::game_plugin::PreMutation),
);
}
}
@@ -353,6 +353,14 @@ pub(super) fn handle_settings_buttons(
**t = on_off_label(settings.0.reduce_motion_mode);
}
}
SettingsButton::ToggleReduceTransparency => {
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_reduce_transparency_text`; the
// glass chrome itself follows via `update_glass_surfaces`
// next frame.
}
SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode {
@@ -363,6 +371,14 @@ pub(super) fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone()));
// 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 => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
+60 -4
View File
@@ -87,6 +87,19 @@ pub struct SettingsChangedEvent(pub Settings);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation;
/// Self-ambiguous club for the screen/overlay systems that touch
/// [`SettingsResource`] (onboarding first-run flag, home difficulty/draw
/// chips, leaderboard opt-in and display name, pause draw toggle, splash
/// reduce-motion reads, …). Each accessor touches its own disjoint fields,
/// and the modal-stacking guard means at most one of these screens is
/// interactive at a time — same-frame access order is provably irrelevant,
/// so members declare `.in_set(SettingsAccess).ambiguous_with(SettingsAccess)`
/// (#143). [`SettingsMutation`] is nested inside the club (its members keep
/// their internal chain). Do NOT add members whose settings read must be
/// same-frame-exact; order those `.after(SettingsMutation)` instead.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsAccess;
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
pub struct SettingsPanel;
@@ -135,10 +148,18 @@ struct HighContrastText;
#[derive(Component, Debug)]
struct ReduceMotionText;
/// Marks the `Text` node showing the current reduce-transparency state.
#[derive(Component, Debug)]
struct ReduceTransparencyText;
/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
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.
#[derive(Component, Debug)]
struct TooltipDelayText;
@@ -273,10 +294,18 @@ enum SettingsButton {
/// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion,
/// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps
/// translucent glass chrome (the floating touch tab bar) for opaque
/// fills so table content never bleeds through UI text.
ToggleReduceTransparency,
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
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
/// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
@@ -353,7 +382,9 @@ impl SettingsButton {
// run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::ToggleReduceTransparency => 63,
SettingsButton::ToggleTouchInputMode => 64,
SettingsButton::CycleUiScale => 65,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
@@ -441,7 +472,12 @@ impl Plugin for SettingsPlugin {
Update,
SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation),
.before(crate::game_plugin::GameMutation)
// Nested in the club: members keep their chain, but
// their pairs against other screen-plugin settings
// accessors are declared irrelevant (#143).
.in_set(SettingsAccess)
.ambiguous_with(SettingsAccess),
)
.add_systems(
Update,
@@ -449,6 +485,11 @@ impl Plugin for SettingsPlugin {
handle_volume_keys,
record_window_geometry_changes,
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()
.in_set(SettingsMutation),
@@ -459,8 +500,12 @@ impl Plugin for SettingsPlugin {
toggle_settings_screen
.before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
scroll_settings_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.chain(),
);
@@ -492,6 +537,7 @@ impl Plugin for SettingsPlugin {
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_replay_move_interval_text,
@@ -499,6 +545,16 @@ impl Plugin for SettingsPlugin {
update_smart_default_size_text,
),
);
app.add_systems(
Update,
(
update_reduce_transparency_text,
// Guards internally on settings-change / newly added
// glass — `Added<GlassSurface>` can't be expressed as
// a resource run condition.
update_glass_surfaces,
),
);
app.add_systems(
Update,
(
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
"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);
}
@@ -317,6 +317,15 @@ fn spawn_accessibility_tab(
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Reduce Transparency",
ReduceTransparencyText,
on_off_label(settings.reduce_transparency_mode),
SettingsButton::ToggleReduceTransparency,
"Replaces the see-through glass toolbar with a solid panel so cards behind it never reduce readability.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
@@ -326,6 +335,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.",
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);
}
@@ -9,8 +9,10 @@ use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::ThemeThumbnailCache;
use crate::ui_glass::{GlassSurface, glass_decorations};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
use bevy::ui::{BackgroundGradient, BorderGradient};
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`.
@@ -330,6 +332,41 @@ pub(super) fn update_reduce_motion_text(
}
}
pub(super) fn update_reduce_transparency_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.reduce_transparency_mode);
}
}
/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the
/// reduce-transparency or high-contrast toggles change. Runs on
/// `SettingsResource` change and on newly added surfaces (so glass
/// spawned after startup picks up an already-active toggle). Parallel to
/// [`update_high_contrast_borders`]: same trigger, different components.
pub(super) fn update_glass_surfaces(
settings: Res<SettingsResource>,
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
added: Query<(), Added<GlassSurface>>,
) {
if !settings.is_changed() && added.is_empty() {
return;
}
let (sheen, rim) = glass_decorations(
settings.0.reduce_transparency_mode,
settings.0.high_contrast_mode,
);
for (mut s, mut r) in &mut surfaces {
*s = sheen.clone();
*r = rim.clone();
}
}
pub(super) fn update_touch_input_mode_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
@@ -515,6 +552,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
/// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives
+12 -1
View File
@@ -99,7 +99,18 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems(
Update,
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(),
// Splash text/fade writers touch UI entities only — they join
// the UiTextFx club (disjoint from board/anim sets) and, for
// the reduce-motion read in the pulse, the screen-plugin
// settings club. Disjoint from the HUD button painters for the
// same entity-domain reason (#143).
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::hud_plugin::HudButtons)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
);
}
}
+106 -28
View File
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
};
/// Bevy resource wrapping the current stats.
@@ -46,6 +46,14 @@ pub struct StatsStoragePath(pub Option<PathBuf>);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct StatsUpdate;
/// Set holding the abandon-recorder (`update_stats_on_new_game`). It must
/// read the outgoing game's `move_count` before any mode-start handler or
/// `GameMutation` clobbers the game state — mode plugins order their reset
/// handlers `.after(AbandonRecord)` via `crate::game_plugin::ModeStart`
/// (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct AbandonRecord;
/// Marker component on the stats overlay root node.
#[derive(Component, Debug)]
pub struct StatsScreen;
@@ -205,10 +213,34 @@ impl Plugin for StatsPlugin {
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
// and a system cannot be both inside a set and individually before a
// set-level ordering constraint.
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
.add_systems(
Update,
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
update_stats_on_new_game
.in_set(AbandonRecord)
.before(GameMutation)
// Reads the shared new-game request stream via its own
// cursor — append order is irrelevant, so it joins the
// self-ambiguous writer club (NewGameRequestWriters must
// not carry set-level edges; see its doc). The explicit
// edges pin it after the pause/settings/selection
// mutators and the pre-mutation game-state spine it
// reads `move_count` from (#143).
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.after(crate::game_plugin::PreMutation)
.after(crate::pause_plugin::PauseMutation)
.after(crate::settings_plugin::SettingsMutation)
.after(crate::selection_plugin::SelectionKeySet),
)
.add_systems(
Update,
update_stats_on_win
.after(GameMutation)
.in_set(StatsUpdate)
// Also emits toasts (streak milestones) — join the
// self-ambiguous writer club (#143).
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.add_systems(
Update,
@@ -216,29 +248,69 @@ impl Plugin for StatsPlugin {
// the NewGameRequestEvent it emits is not visible to
// update_stats_on_new_game in the same frame — otherwise
// record_abandoned() fires twice on every forfeit (#21).
// It drains the ForfeitEvent the pause/forfeit modal chain
// emits, and shares the new-game request stream with the
// other writers (#143).
handle_forfeit
.before(GameMutation)
.before(update_stats_on_new_game),
.before(update_stats_on_new_game)
.after(crate::pause_plugin::PauseMutation)
.after(crate::selection_plugin::SelectionKeySet)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
.add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
//
// Stats overlay UI: one deterministic chain — every member
// touches the replay-selector state or the overlay's nodes, and
// handle_watch_replay_button resets the live game, so the chain
// sits between GameMutation and the auto-complete driver on the
// game-state timeline (#143). The toast-emitting members join
// the self-ambiguous InfoToastWriters set so the toast pipeline
// (AnimFx) drains them this frame.
.add_systems(
Update,
(
refresh_replay_history_on_win,
handle_replay_selector_buttons,
repaint_replay_selector_caption,
repaint_replay_selector_detail,
// Marker-filtered Text repaints — disjoint from the
// HUD text painters (UiTextFx club, #143).
repaint_replay_selector_caption
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
repaint_replay_selector_detail
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
handle_watch_replay_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
handle_copy_share_link_button
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain(),
.chain()
.after(GameMutation)
// The overlay repaints read this frame's settled stats,
// progress, and achievement evaluation (level line,
// streak).
.after(StatsUpdate)
.after(crate::progress_plugin::ProgressUpdate)
.after(crate::achievement_plugin::AchievementEval)
.before(crate::auto_complete_plugin::AutoComplete),
)
.add_systems(Update, scroll_stats_panel)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<StatsScrollable>,
scroll_stats_panel
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
)
.add_systems(
Update,
crate::ui_modal::touch_scroll_panel::<StatsScrollable>
.in_set(crate::ui_modal::PanelScroll)
.ambiguous_with(crate::ui_modal::PanelScroll),
);
}
}
@@ -719,25 +791,17 @@ pub(crate) fn spawn_stats_body(
},
))
.with_children(|body| {
// First-launch caption — sits above the grid as gentle nudge so
// First-launch empty state (Phase L) — sits above the grid so
// the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state.
if is_first_launch {
body.spawn((
Text::new("Play a game to start tracking stats."),
TextFont {
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
crate::ui_modal::spawn_empty_state(
body,
"\u{2663}",
"Play a game to start tracking stats.",
None,
font_res,
);
}
// --- primary stat cells grid ---
@@ -893,6 +957,20 @@ pub(crate) fn spawn_replays_body(
selected_index: usize,
font_res: Option<&FontResource>,
) {
// Standard empty state (Phase L) — the selector / Watch / Copy
// controls only spawn when there is something to select, so their
// handlers all no-op via their existing empty-query paths.
if replays.is_empty() {
crate::ui_modal::spawn_empty_state(
card,
"\u{2666}",
"No replays yet.",
Some("Every win records a replay here automatically."),
font_res,
);
return;
}
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
font: font_handle,
+6 -1
View File
@@ -112,7 +112,12 @@ impl Plugin for TablePlugin {
// paint pipeline — markers and cards share Sprite/
// Transform access (#143).
(
apply_theme_on_settings_change,
// Drains SettingsChangedEvent via cursor — a home
// deal-option edit (post-mutation SettingsAccess
// writer) surfacing one frame later repaints one
// frame later, which is invisible (#143).
apply_theme_on_settings_change
.ambiguous_with(crate::settings_plugin::SettingsAccess),
apply_hint_pile_highlight,
tick_hint_pile_highlights,
sync_pile_marker_visibility.after(GameMutation),
+23 -17
View File
@@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
@@ -528,25 +528,31 @@ fn spawn_store_modal(
match catalog_state {
CatalogState::Idle | CatalogState::Loading => {
card.spawn((
Text::new("Loading catalog…"),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_empty_state(
card,
"\u{21BB}",
"Loading the catalog\u{2026}",
None,
font_res,
);
}
CatalogState::Error(msg) => {
card.spawn((
Text::new(format!("Could not load the catalog: {msg}")),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_empty_state(
card,
"!",
"Couldn't load the catalog.",
Some(msg.as_str()),
font_res,
);
}
CatalogState::Loaded(entries) if entries.is_empty() => {
card.spawn((
Text::new("The server has no themes yet."),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_empty_state(
card,
"#",
"The server has no themes yet.",
Some("Themes dropped into the server's theme_store folder appear here."),
font_res,
);
}
CatalogState::Loaded(entries) => {
for entry in entries {
+32 -4
View File
@@ -109,13 +109,41 @@ impl Plugin for TimeAttackPlugin {
.add_message::<NewGameRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<InfoToastEvent>()
// ModeStart places it between the stats abandon-recorder and
// GameMutation; it shares the new-game request/toast streams
// with the other writers (#143).
.add_systems(
Update,
handle_start_time_attack_request.before(GameMutation),
handle_start_time_attack_request
.in_set(crate::game_plugin::ModeStart)
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
// One post-mutation chain for the TimeAttackResource writers:
// clock tick, win auto-deal, then the debounced disk save.
// The start handler (ModeStart, pre-mutation) is ordered
// against all three transitively. auto_deal's new-game request
// is drained wholesale next frame, so it joins the writer club
// (#143). PausedResource is settled: PauseMutation < GameMutation.
.add_systems(
Update,
(
advance_time_attack,
auto_deal_on_time_attack_win
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
auto_save_time_attack_session,
)
.chain()
.after(GameMutation)
// The HUD clock (after AutoComplete) and the ended-toast
// handler (AnimFx) read what this chain writes — settle
// it before both (#143).
.before(crate::auto_complete_plugin::AutoComplete)
.before(crate::animation_plugin::AnimFx),
)
.add_systems(Update, advance_time_attack)
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation))
.add_systems(Update, auto_save_time_attack_session)
.add_systems(Last, save_time_attack_session_on_exit);
}
}
@@ -100,7 +100,11 @@ impl Plugin for TouchSelectionPlugin {
update_touch_selection_highlight,
)
.chain()
.after(GameMutation),
.after(GameMutation)
// Selection cleanup and highlight placement settle before
// the board painters repaint (and before resync's synthetic
// StateChanged / fan-frac's layout write land) (#143).
.before(crate::card_plugin::BoardVisuals),
);
}
}
+281
View File
@@ -0,0 +1,281 @@
//! "Liquid glass" material for floating UI surfaces.
//!
//! Produces the translucent, light-catching treatment used by the floating
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
//! vertical sheen, a specular rim highlight around the border, and a soft
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
//! UI pipeline has no backdrop-blur concept, so this module approximates
//! frosted glass with layered gradients instead of sampling what's behind
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
//! extra render passes, and identical behaviour on desktop, Android, and
//! WebGL2.
//!
//! # Swapping the treatment
//!
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
//! render target) can replace this module's internals without touching any
//! caller — that boundary is the reason this file exists separately from
//! `hud_plugin/tab_bar.rs`.
//!
//! # Departure from the Terminal design system
//!
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
//! bar is a deliberate, user-approved exception for the floating touch tab
//! bar only; do not reuse this material for modals or the top HUD band
//! without a matching design decision.
use bevy::prelude::*;
use bevy::ui::{
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
};
use crate::ui_theme::{BG_ELEVATED, BORDER_SUBTLE, BORDER_SUBTLE_HC};
/// Border width every glass surface must reserve in its `Node::border` so
/// the rim gradient has a strip to paint into.
pub const GLASS_BORDER_PX: f32 = 1.0;
/// Marker on every node that received [`glass_surface`]. The settings
/// plugin's `update_glass_surfaces` retargets these when the player
/// toggles reduce-transparency or high-contrast, swapping the gradients
/// in place via [`glass_decorations`].
#[derive(Component, Debug)]
pub struct GlassSurface;
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
/// opacity so the felt and cards remain visible through the bar while
/// keeping icon/label contrast comfortable.
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
/// Fill at the *top* of the sheet — the same glass lifted towards white,
/// reading as the curved surface catching overhead light.
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
/// Rim highlight at the top edge — the bright specular line where a real
/// glass pill would catch the light source.
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
/// rather than a drawn outline.
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
/// Rim at the bottom edge — a faint secondary catch-light, as on the
/// underside of a curved surface above a bright table.
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
/// Drop shadow under the floating surface.
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
/// High-contrast fill — same glass, far less see-through, so text and
/// icons keep contrast over any card art. Per `design-system.md`
/// §Accessibility the HC toggle trades aesthetics for legibility.
const GLASS_FILL_TOP_HC: Color = Color::srgba(0.20, 0.21, 0.23, 0.90);
/// High-contrast fill at the bottom of the sheet.
const GLASS_FILL_BOTTOM_HC: Color = Color::srgba(0.055, 0.055, 0.063, 0.88);
/// High-contrast rim at the top edge — matches the luminance of
/// `BORDER_SUBTLE_HC` so the bar's outline reads as strongly as every
/// other HC-boosted border.
const GLASS_RIM_TOP_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.75);
/// High-contrast rim at the midpoint.
const GLASS_RIM_MID_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.35);
/// High-contrast rim at the bottom edge.
const GLASS_RIM_BOTTOM_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.50);
/// Visual components for a floating glass surface.
///
/// The caller owns the `Node` and must set two fields for the material to
/// render correctly:
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
/// paints into the border strip and is invisible without it.
/// - `border_radius` — half the node height for a fully-round pill.
///
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
pub fn glass_surface() -> impl Bundle {
let (sheen, rim) = glass_decorations(false, false);
(
GlassSurface,
sheen,
rim,
// Soft shadow underneath sells the "floating above the table" read.
BoxShadow(vec![ShadowStyle {
color: GLASS_SHADOW,
x_offset: Val::Px(0.0),
y_offset: Val::Px(6.0),
spread_radius: Val::Px(0.0),
blur_radius: Val::Px(16.0),
}]),
)
}
/// The sheen + rim pair for the requested accessibility state. The
/// settings plugin overwrites every [`GlassSurface`]'s components with
/// these when the relevant toggles change.
///
/// - Default: translucent fill with a top-lit sheen and a specular rim.
/// - `high_contrast`: same shape, near-opaque fill, and a rim boosted to
/// `BORDER_SUBTLE_HC` luminance so the outline stays legible.
/// - `reduce_transparency`: flat opaque `BG_ELEVATED` fill and a flat
/// border — no see-through at all. Combined with `high_contrast` the
/// flat border brightens to `BORDER_SUBTLE_HC`.
pub fn glass_decorations(
reduce_transparency: bool,
high_contrast: bool,
) -> (BackgroundGradient, BorderGradient) {
if reduce_transparency {
// "Gradients" with a single stop render as flat fills — reusing the
// same component types means the settings toggle swaps values, not
// component sets.
let border = if high_contrast {
BORDER_SUBTLE_HC
} else {
BORDER_SUBTLE
};
return (
BackgroundGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![ColorStop::new(BG_ELEVATED, Val::Percent(0.0))],
)
.into(),
]),
BorderGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![ColorStop::new(border, Val::Percent(0.0))],
)
.into(),
]),
);
}
let (fill_top, fill_bottom, rim_top, rim_mid, rim_bottom) = if high_contrast {
(
GLASS_FILL_TOP_HC,
GLASS_FILL_BOTTOM_HC,
GLASS_RIM_TOP_HC,
GLASS_RIM_MID_HC,
GLASS_RIM_BOTTOM_HC,
)
} else {
(
GLASS_FILL_TOP,
GLASS_FILL_BOTTOM,
GLASS_RIM_TOP,
GLASS_RIM_MID,
GLASS_RIM_BOTTOM,
)
};
(
// Sheen: one top-to-bottom linear gradient carries both the fill and
// the lighting so there is a single source of truth for the surface
// colour (a separate `BackgroundColor` would just be painted over).
BackgroundGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![
ColorStop::new(fill_top, Val::Percent(0.0)),
ColorStop::new(fill_bottom, Val::Percent(60.0)),
],
)
.into(),
]),
// Specular rim: bright at the top edge, fading out through the
// sides, with a faint return at the bottom.
BorderGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![
ColorStop::new(rim_top, Val::Percent(0.0)),
ColorStop::new(rim_mid, Val::Percent(55.0)),
ColorStop::new(rim_bottom, Val::Percent(100.0)),
],
)
.into(),
]),
)
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ui::Gradient;
/// The bundle must insert all three visual components — a regression
/// here (e.g. a refactor dropping the rim) would silently flatten the
/// glass into a plain translucent box.
#[test]
fn glass_surface_inserts_sheen_rim_and_shadow() {
let mut world = World::new();
let e = world.spawn(glass_surface()).id();
assert!(world.get::<BackgroundGradient>(e).is_some());
assert!(world.get::<BorderGradient>(e).is_some());
assert!(world.get::<BoxShadow>(e).is_some());
}
/// The fill must stay translucent (that is the whole point of glass) but
/// opaque enough that text keeps contrast over busy card art.
#[test]
fn glass_fill_alpha_stays_in_readable_band() {
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
let a = fill.alpha();
assert!(
(0.4..=0.85).contains(&a),
"glass fill alpha {a} outside readable 0.40.85 band"
);
}
}
/// The rim gradient must actually glint: strictly brightest at the top,
/// dimmest in the middle.
#[test]
fn rim_highlight_peaks_at_top() {
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
assert!(GLASS_RIM_TOP_HC.alpha() > GLASS_RIM_BOTTOM_HC.alpha());
assert!(GLASS_RIM_BOTTOM_HC.alpha() > GLASS_RIM_MID_HC.alpha());
}
/// Reduce-transparency must produce fully opaque fills — the entire
/// point of the toggle is that nothing shows through.
#[test]
fn reduce_transparency_is_fully_opaque() {
for high_contrast in [false, true] {
let (sheen, _) = glass_decorations(true, high_contrast);
for gradient in &sheen.0 {
let Gradient::Linear(linear) = gradient else {
panic!("reduce-transparency sheen must stay linear");
};
for stop in &linear.stops {
assert_eq!(
stop.color.alpha(),
1.0,
"opaque variant leaked translucency (hc={high_contrast})"
);
}
}
}
}
/// High-contrast glass must be meaningfully less transparent than the
/// default, and its rim meaningfully brighter — otherwise the toggle
/// does nothing perceptible on this surface.
#[test]
fn high_contrast_boosts_fill_and_rim() {
assert!(GLASS_FILL_TOP_HC.alpha() >= GLASS_FILL_TOP.alpha() + 0.15);
assert!(GLASS_FILL_BOTTOM_HC.alpha() >= GLASS_FILL_BOTTOM.alpha() + 0.15);
assert!(GLASS_RIM_TOP_HC.alpha() >= GLASS_RIM_TOP.alpha() + 0.25);
}
/// `glass_surface()` must carry the marker the settings applier
/// retargets — without it the accessibility toggles silently skip
/// the bar.
#[test]
fn glass_surface_carries_retarget_marker() {
let mut world = World::new();
let e = world.spawn(glass_surface()).id();
assert!(world.get::<GlassSurface>(e).is_some());
}
}
+149 -4
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -328,6 +328,80 @@ pub fn spawn_modal_body_text(
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
}
/// Standard empty / loading / error surface (Phase L): a large glyph,
/// a headline, and an optional secondary detail line, centred in the
/// available space. Every async or possibly-empty surface (leaderboard,
/// replays, theme store, first-launch stats) renders through this so
/// "nothing here yet" reads identically everywhere.
///
/// A next-step action is composition, not configuration: spawn a
/// [`spawn_modal_button`] (or any control) in the same parent right
/// after this call.
///
/// `glyph` MUST come from FiraMono-covered ranges — card suits
/// (U+26602666), Arrows (U+219021FF), or ASCII. The Geometric Shapes
/// block renders as tofu on Android (§10 / §11).
pub fn spawn_empty_state(
parent: &mut ChildSpawnerCommands,
glyph: &str,
headline: &str,
detail: Option<&str>,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_glyph = TextFont {
font: font_handle.clone(),
font_size: TYPE_DISPLAY,
..default()
};
let font_headline = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_detail = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
parent
.spawn((
EmptyStateBlock,
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5),
width: Val::Percent(100.0),
..default()
},
))
.with_children(|block| {
block.spawn((
Text::new(glyph.to_string()),
font_glyph,
TextColor(TEXT_DISABLED),
));
block.spawn((
Text::new(headline.to_string()),
font_headline,
TextColor(TEXT_PRIMARY),
));
if let Some(detail) = detail {
block.spawn((
Text::new(detail.to_string()),
font_detail,
TextColor(TEXT_SECONDARY),
));
}
});
}
/// Marker on every [`spawn_empty_state`] block, mostly for tests.
#[derive(Component, Debug)]
pub struct EmptyStateBlock;
/// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`.
///
@@ -472,6 +546,15 @@ pub fn spawn_tab_chip<M: Component>(
// Generic touch-scroll helper
// ---------------------------------------------------------------------------
/// Self-ambiguous writer set for the per-modal scroll systems (mouse-wheel
/// and touch variants). Every member writes `ScrollPosition`, but each
/// targets only its own modal's scrollable body via a marker component, and
/// the modal-stacking guard means at most one such panel exists at a time —
/// the writes are disjoint by design, so members declare
/// `.in_set(PanelScroll).ambiguous_with(PanelScroll)` (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct PanelScroll;
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
/// touch pan. Add this as a system for each scrollable modal panel:
///
@@ -766,7 +849,11 @@ impl Plugin for UiModalPlugin {
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::hud_plugin::HudButtons),
.ambiguous_with(crate::hud_plugin::HudButtons)
// Reads only the anim-speed settings field — disjoint from
// the screen-plugin club's writes (#143).
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
);
// Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs
@@ -1184,4 +1271,62 @@ mod tests {
"exactly one of the two stacked dismissible modals should remain"
);
}
// -----------------------------------------------------------------------
// Phase L: standard empty state
// -----------------------------------------------------------------------
/// The helper spawns glyph + headline (+ detail when given) under one
/// marked block, so every empty surface renders identically.
#[test]
fn empty_state_spawns_expected_structure() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
{
let world = app.world_mut();
let mut commands = world.commands();
commands.spawn(Node::default()).with_children(|parent| {
spawn_empty_state(
parent,
"\u{2660}",
"Nothing here.",
Some("Do the thing."),
None,
);
spawn_empty_state(parent, "!", "No detail variant.", None, None);
});
}
app.update();
let blocks = app
.world_mut()
.query_filtered::<Entity, With<EmptyStateBlock>>()
.iter(app.world())
.count();
assert_eq!(blocks, 2, "each call spawns exactly one marked block");
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
for expected in [
"\u{2660}",
"Nothing here.",
"Do the thing.",
"!",
"No detail variant.",
] {
assert!(
texts.iter().any(|t| t == expected),
"missing text node {expected:?}; got {texts:?}"
);
}
assert_eq!(
texts.len(),
5,
"the detail-less variant must not spawn a detail node"
);
}
}
+17 -1
View File
@@ -93,7 +93,23 @@ impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip_overlay)
.add_systems(Update, (track_tooltip_hover, show_or_hide_tooltip).chain());
// The tooltip pair only touches the tooltip overlay entity and
// reads settings for the enable flag — chrome-disjoint from the
// other UiTextFx members, and a settings read that may lag a
// frame joins the SettingsAccess club (#143).
.add_systems(
Update,
(track_tooltip_hover, show_or_hide_tooltip)
.chain()
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx)
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess)
// The tooltip node is neither a board entity nor a pile
// marker; the visibility writes can't collide (#143).
.ambiguous_with(crate::card_plugin::BoardVisuals)
.ambiguous_with(crate::table_plugin::MarkerVisuals),
);
}
}
+5 -5
View File
@@ -10,8 +10,8 @@ use solitaire_data::{
};
use crate::events::{GameWonEvent, XpAwardedEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath};
use crate::resources::GameStateResource;
/// Fired when the player has just completed a weekly goal.
@@ -33,11 +33,11 @@ impl Plugin for WeeklyGoalsPlugin {
.add_systems(Startup, roll_weekly_goals_on_startup)
// Run after GameMutation (so GameWonEvent is available) and
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
// Ordering comes from the mode-completion chain configured in
// progress_plugin (#143).
.add_systems(
Update,
evaluate_weekly_goals
.after(GameMutation)
.after(ProgressUpdate),
evaluate_weekly_goals.in_set(crate::progress_plugin::WeeklyGoalsEval),
);
}
}
+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);
}
}
+133 -9
View File
@@ -18,7 +18,7 @@
//! shake duration elapses.
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use solitaire_core::game_state::{GameMode, GameState};
use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed;
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
/// score-breakdown reveal can format the mode-multiplier row
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
pub mode: GameMode,
/// Per-move-type recap of the winning game, e.g.
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
/// Built from the upstream session counters at win time; empty when
/// every counter is zero (synthesised test wins).
pub move_detail: String,
}
/// Formats the per-move-type recap line for the win modal from the
/// upstream session counters. Zero-valued components are omitted so the
/// line stays short; returns an empty string when nothing was counted
/// (only possible for synthesised test wins).
fn build_move_detail(game: &GameState) -> String {
let mut parts: Vec<String> = Vec::new();
let foundation = game.move_to_foundation_count();
if foundation > 0 {
parts.push(format!("{foundation} to foundation"));
}
let tableau = game.move_to_tableau_count();
if tableau > 0 {
parts.push(format!("{tableau} tableau moves"));
}
let flips = game.flip_up_count();
if flips > 0 {
parts.push(format!("{flips} flips"));
}
let recycles = game.recycle_count();
if recycles > 0 {
parts.push(format!("{recycles} recycles"));
}
let returns = game.move_from_foundation_count();
if returns > 0 {
parts.push(format!("{returns} foundation returns"));
}
parts.join(" \u{00B7} ")
}
/// Builds a human-readable XP breakdown string for the win modal.
@@ -232,20 +266,54 @@ impl Plugin for WinSummaryPlugin {
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
Update,
cache_win_data.after(GameMutation).before(StatsUpdate),
cache_win_data
.after(GameMutation)
.before(StatsUpdate)
// Must also read pre-award progress (old level for the
// level-up row) and emits the personal-best toast.
.before(crate::progress_plugin::ProgressUpdate)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.add_systems(
Update,
// One deterministic chain, late on the frame: after AnimFx
// so the unlock/XP event streams it drains are settled by
// both achievement evaluators (win + replay-completion run
// before AnimFx). Members join the request/toast writer
// clubs they append to; the camera shake and the score
// reveal touch entity domains disjoint from the card/HUD
// painters (#143).
(
collect_session_achievements,
spawn_win_summary_after_delay,
handle_win_summary_buttons,
collect_session_achievements
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
spawn_win_summary_after_delay
.in_set(crate::settings_plugin::SettingsAccess)
.ambiguous_with(crate::settings_plugin::SettingsAccess),
handle_win_summary_buttons
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
close_overlay_on_watch_replay,
handle_win_summary_keyboard,
apply_screen_shake,
reveal_score_breakdown,
handle_win_summary_keyboard
.in_set(crate::game_plugin::NewGameRequestWriters)
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
.before(crate::ui_focus::FocusKeys),
apply_screen_shake
.ambiguous_with(crate::feedback_anim_plugin::FeedbackFx)
// Shakes the camera; hint ghosts are their own
// entity domain (#143).
.ambiguous_with(crate::feedback_anim_plugin::HintGhostFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
reveal_score_breakdown
.in_set(crate::ui_theme::UiTextFx)
.ambiguous_with(crate::ui_theme::UiTextFx),
)
.after(GameMutation),
.chain()
.after(GameMutation)
.after(crate::animation_plugin::AnimFx),
);
}
}
@@ -492,6 +560,7 @@ fn cache_win_data(
pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count();
pending.mode = game.0.mode;
pending.move_detail = build_move_detail(&game.0);
if is_new_record {
toast.write(InfoToastEvent("New Record!".to_string()));
@@ -913,6 +982,18 @@ fn spawn_overlay(
));
}
// Move-type recap (same quiet styling as the XP breakdown)
if !pending.move_detail.is_empty() {
card.spawn((
Text::new(pending.move_detail.clone()),
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Achievements unlocked this game — at most 3 shown explicitly;
// excess is summarised with "...and N more".
if !session.names.is_empty() {
@@ -1288,6 +1369,49 @@ mod tests {
assert_eq!(p.mode, GameMode::Classic);
}
#[test]
fn build_move_detail_fresh_game_is_empty() {
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
assert!(
build_move_detail(&game).is_empty(),
"no moves yet, so the recap line must be empty"
);
}
#[test]
fn build_move_detail_reports_played_move_types() {
use solitaire_core::KlondikeInstruction;
// Drive a real deal forward so the upstream counters accumulate:
// prefer foundation moves, then anything else, drawing as needed.
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
for _ in 0..80 {
let instructions = game.possible_instructions();
let next = instructions
.iter()
.copied()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
.or_else(|| instructions.into_iter().next());
match next {
Some(i) => {
let _ = game.apply_instruction(i);
}
None => break,
}
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
break;
}
}
let detail = build_move_detail(&game);
assert!(
detail.contains("to foundation"),
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
);
assert!(
!detail.contains("0 "),
"zero-valued components must be omitted; got: {detail}"
);
}
#[test]
fn build_xp_detail_slow_win_with_undo() {
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
+4 -10
View File
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS);
});
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
/// forwards to (step_idx - 1) without rendering intermediate frames, then
/// renders once so the CSS transition animates each card to its previous
/// position.
/// Step the player back one move via the wasm-side seek (rewinds to the
/// recorded deal and fast-forwards internally), then renders once so the
/// CSS transition animates each card to its previous position.
function stepBack() {
if (!player || player.step_idx() === 0) return;
if (playInterval) {
@@ -326,12 +325,7 @@ function stepBack() {
playInterval = null;
btnPlay.textContent = "▶ Play";
}
const target = player.step_idx() - 1;
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
render(player.seek(player.step_idx() - 1));
btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false;
+91
View File
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
#[wasm_bindgen]
pub struct ReplayPlayer {
game: GameState,
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
/// can rewind without reparsing the replay JSON.
initial: GameState,
moves: Vec<KlondikeInstruction>,
step_idx: usize,
}
@@ -144,12 +147,30 @@ impl ReplayPlayer {
// the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self {
initial: game.clone(),
game,
moves,
step_idx: 0,
})
}
/// Jump to `step` (clamped to the move count): the board state after
/// `step` moves have been applied. Rewinds by resetting to the stored
/// initial deal, then fast-forwards — a few hundred instruction
/// applications, microseconds in practice.
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
let target = step.min(self.moves.len());
if target < self.step_idx {
self.game = self.initial.clone();
self.step_idx = 0;
}
while self.step_idx < target {
self.game.apply_instruction(self.moves[self.step_idx])?;
self.step_idx += 1;
}
Ok(self.snapshot())
}
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
if self.step_idx >= self.moves.len() {
@@ -236,6 +257,25 @@ impl ReplayPlayer {
}
}
/// Jump directly to `step` moves applied (clamped to the move count)
/// and return the snapshot there. Backwards seeks rewind to the
/// recorded deal and fast-forward, so any position is O(replay length)
/// at worst — no JSON reparse, no intermediate renders.
///
/// Throws `"replay_desync"` if a recorded move is illegal during the
/// fast-forward (corrupt recording).
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
match self.seek_native(step) {
Ok(snap) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains.
pub fn total_steps(&self) -> usize {
self.moves.len()
@@ -1062,6 +1102,57 @@ mod tests {
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
}
/// `seek` must land on exactly the state produced by stepping — both
/// forwards (fast-forward from the current position) and backwards
/// (rewind to the recorded deal, then fast-forward).
#[test]
fn seek_matches_stepping_in_both_directions() {
let mut game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
for _ in 0..24 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
game.apply_legal_move_native(idx).expect("advance game");
}
let replay_json = game
.replay_export_native(60, "2026-07-10")
.expect("export replay");
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
let total = stepped.total_steps();
assert!(total >= 4, "test needs a few moves, got {total}");
// Forward: step A to k, seek B to k, compare snapshots.
let k = total / 2;
for _ in 0..k {
stepped.step_native().expect("step").expect("mid-replay");
}
let sought = seeker.seek_native(k).expect("seek forward");
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
// Backward: seek B to k - 2 and compare against a fresh stepper.
let back = k - 2;
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
for _ in 0..back {
fresh.step_native().expect("step").expect("mid-replay");
}
let sought_back = seeker.seek_native(back).expect("seek backward");
assert_eq!(
sought_back,
fresh.snapshot(),
"backward seek diverged at {back}"
);
// Clamping: past-the-end seeks stop at the final state.
let end = seeker.seek_native(usize::MAX).expect("seek to end");
assert_eq!(end.step_idx, total);
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {