Compare commits

...

6 Commits

Author SHA1 Message Date
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
6 changed files with 408 additions and 82 deletions
+19
View File
@@ -6,6 +6,25 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [0.46.0] — 2026-07-13
### Added ### Added
+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)"
);
}
}
+9 -8
View File
@@ -14,14 +14,14 @@ use crate::platform::{
}; };
use crate::{ use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
@@ -117,6 +117,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin) .add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin) .add_plugins(ContextualTipsPlugin)
.add_plugins(CheatSheetPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
+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);
}
}
}
+3
View File
@@ -15,6 +15,7 @@ pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cheat_sheet_plugin;
pub mod contextual_tips_plugin; pub mod contextual_tips_plugin;
pub mod core_game_plugin; pub mod core_game_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
@@ -27,6 +28,7 @@ pub mod font_plugin;
pub mod game_plugin; pub mod game_plugin;
pub mod help_plugin; pub mod help_plugin;
pub mod home_plugin; pub mod home_plugin;
pub mod hotkeys;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
@@ -95,6 +97,7 @@ pub use card_plugin::{
pub use challenge_plugin::{ pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label, CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
}; };
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
pub use contextual_tips_plugin::ContextualTipsPlugin; pub use contextual_tips_plugin::ContextualTipsPlugin;
pub use core_game_plugin::CoreGamePlugin; pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
+14 -74
View File
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8); pub struct OnboardingSlideIndex(pub u8);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the // Slide data — the hotkey slide renders the essential subset of the
// two screens stay in sync without a shared abstraction. // shared table in `crate::hotkeys` (Phase J unified the previously
// diverging copies here and in help_plugin).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
}
/// Most-used shortcuts from the `help_plugin` canonical list.
///
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -446,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res); spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as HelpScreen. // Vertical list of `key — description` rows, same chip style as
for row in HOTKEYS { // HelpScreen. Essential subset only — the full table lives on
// the hold-`/` cheat sheet.
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
card.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -826,22 +773,15 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Hotkey list is non-empty (guards against accidental truncation) // Hotkey slide renders a non-empty essential subset (the table's own
// integrity tests live in `crate::hotkeys`)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
fn hotkey_list_is_non_empty() { fn hotkey_slide_subset_is_non_empty() {
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty"); assert!(
} crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
"the onboarding slide needs at least one essential hotkey"
#[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"
);
}
} }
} }