Compare commits

..

5 Commits

Author SHA1 Message Date
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
7 changed files with 368 additions and 67 deletions
+19
View File
@@ -6,6 +6,25 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+22
View File
@@ -215,6 +215,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 +228,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 +487,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",
);
}
+26 -30
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,
};
// ---------------------------------------------------------------------------
@@ -536,11 +536,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 +642,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
+23 -17
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.
@@ -719,25 +719,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 +885,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,
+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 {
+135 -3
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`.
///
@@ -1184,4 +1258,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"
);
}
}