Merge pull request 'feat(engine): tabbed Settings panel — Phase A of the menu redesign' (#160) from feat/settings-tabs into master
Build and Deploy / build-and-push (push) Successful in 2m23s
Web WASM Rebuild / rebuild (push) Waiting to run
Test / test (push) Successful in 37m11s

This commit was merged in pull request #160.
This commit is contained in:
2026-07-09 04:59:11 +00:00
6 changed files with 817 additions and 316 deletions
+204
View File
@@ -0,0 +1,204 @@
# Menu UX Redesign — July 2026
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
out of scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07)
| Surface | Today | Pain |
| --- | --- | --- |
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
## Phase A — Settings tabs (highest pain, lowest risk)
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
Accessibility · Account`.
- *Appearance* = today's Cosmetic (card back, background, card theme,
theme store, import).
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
high-contrast, reduce-motion, touch input mode, tooltip delay.
- *Account* = Sync + Privacy merged.
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
unfolded Fold, desktop): left rail, rows in two columns.
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
cover screen without scrolling.
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
resource. The `SettingsButton` enum, input handlers, and persistence are
untouched, so the 25 settings tests and the ambiguity gate stay green.
## Phase B — Home becomes a real home
- **Hierarchy, top to bottom:**
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
2. Hero `New Game` — one tap, reuses last mode + deal options.
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
Seed) — smaller cards, glyph + name + one-line description on wide only.
4. Stats strip moves to the bottom (or right pane when unfolded).
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
level into a disclosure on the Classic card / New Game hero — they are
Classic-mode concerns, not global ones.
- Cancel becomes `Back to table` and only renders when a game exists.
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
daily/weekly right.
## Phase C — HUD menu consolidation
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
path opens Home).
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
section dividers in the existing popover, not a new widget.
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
modal (most already do via `ScrimDismissible`; sweep the stragglers).
## Phase D — Validation gates
- Optional Stitch mockups (Terminal design-system asset
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
Home + Settings before implementing Phase B. Phase A is safe to build
straight in-engine.
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
## Sequencing & risk
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low (layout-only) | — |
| B — Home hierarchy | 12 sessions | Medium (touches launch flow) | D mockups if wanted |
| C — Menu grouping | small | Low | B (Modes removal) |
## Phase E — "You" hub (reuses the Phase A tab component)
Profile, Stats, Achievements, and the replay browser are four separate
modals today, reached through the HUD popover one at a time. Fold them
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
four destinations and makes the tab component pay for itself twice.
Existing per-screen systems keep their markers/handlers; only the outer
shell changes (same trick as Phase A).
## Phase F — Mobile ergonomics (Fold-first)
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
score-only band stays on top. Desktop keeps the current top band.
Biggest one-handed-play win available; folded posture is tall and
top-heavy today.
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
- Both are additive; game stays fully playable with the top band alone
(UI-first rule §3.3 satisfied).
## Phase G — Post-win flow
Win summary today is a stats dump with a close. Give it an action
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
(the upload already returns a share URL; surface it here with copy
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
4 taps to 1.
## Phase H — Feedback polish (small, bundling candidates)
- Unify queued vs. immediate toast styling (two subtly different styles
exist today) and anchor both to one position; consider a 5-item toast
history on the stats screen.
- Theme-store modal: render the preview PNGs the server already serves
(v1 is text-only rows).
- Hint: optional ghost-motion preview of the suggested move instead of a
static highlight (auto-disabled under reduce-motion).
## Phase I — Onboarding & discoverability
The first-run onboarding exists, but everything learned after it is
invisible: the radial menu (long-press/right-click), hint cycling,
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
fired by the situation, not a tour (first stall → hint tip; first
long-press-able stack → radial tip; each shows once, stored in
Settings like `shown_achievement_onboarding` already is). Plus a
one-shot **"What's new"** card on first launch after a version bump —
ObtainX updates are silent today, so shipped features go unnoticed
(nobody will find the theme store on their own).
## Phase J — Keyboard & focus completeness (desktop + web)
`ui_focus` already gives deterministic Esc order and tab-walk
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
keyboard-only play has never been audited end to end. Deliverables: every
modal reachable/dismissable without a mouse, a visible focus ring styled
to the Terminal system (current focus state is subtle), and a hotkey
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
of the static Help text. Web build (Rhys) benefits most.
## Phase K — UI scale & touch-target accessibility
All type sizes flow through `TYPE_*` tokens and layout through
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
cheap to wire and genuinely useful on the Fold's dense cover screen.
Pair with: respect Android system font scale on first run (seed the
setting from it), and a one-time audit that every interactive element
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
and picker swatches are the suspects). Lands in the Accessibility tab
that Phase A creates.
## Phase L — Empty & loading states, standardized
Async/empty surfaces each improvise today: leaderboard with no entries,
stats with no games, replay browser with no replays, theme store
loading/error (just added), sync status text. Define ONE pattern —
glyph + one-line explanation + a single next-step action — as a
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
onto it. Small, mechanical, big perceived-quality win.
## Phase M — Sync transparency & data stewardship
The sync layer returns `ConflictReport`s ("data is never silently
discarded") — but no UI ever shows them; players can't tell what a merge
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
values" → tap for detail in the Account tab), a visible last-synced
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
local **data export/import** (zip of the JSON saves) for device
migrations without a server. Closes the trust loop the server work
opened.
## Roadmap tie-ins (already in flight elsewhere)
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
7th card once engine work lands — the 2×3 grid in Phase B should be
designed as N-card flow from day one. Spider UI also needs positional
card→entity keys (documented in spider.rs module docs).
- **Show solution** (feat/solution-line, in flight): lands in the pause
menu; Phase C's grouping should leave room for it under a "Game"
cluster if the pause menu grows.
## Sequencing (updated)
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
| E — "You" hub | ~1 session | Low | A's tab component |
| C — Menu grouping | small | Low | E (popover shrinks) |
| B — Home hierarchy | 12 sessions | Medium | D mockups if wanted |
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
| G — Post-win flow | small | Low | — |
| H — Feedback polish | small each | Low | — |
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
| L — Empty-state standardization | small | Low | — |
| M — Sync transparency & export | ~1 session | LowMedium | A (Account tab) |
Suggested order: **A → E → C** (one arc: the tab component and the menu
slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions
1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.**
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
tucked under a "More" card?
4. Stitch mockups for Phase B, or iterate directly in-engine?
5. Phase F bottom bar: touch-only (proposed) or also desktop?
@@ -418,6 +418,25 @@ pub(super) fn handle_settings_buttons(
} }
} }
/// Switches the active Settings tab when a chip is pressed. The
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
/// change detection on [`ActiveSettingsTab`].
pub(super) fn handle_tab_buttons(
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveSettingsTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
// Compare before writing so re-clicking the active chip doesn't
// trip change detection and rebuild the panel for nothing.
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within /// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit. /// Bevy's 16-parameter system limit.
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct AnalyticsEnabledText; struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone())) .insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>() .init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>() .init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>() .init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
@@ -422,8 +468,16 @@ impl Plugin for SettingsPlugin {
if self.ui_enabled { if self.ui_enabled {
app.add_systems( app.add_systems(
Update, Update,
(
// Chained: a tab click must observe this frame's
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
( (
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+104 -46
View File
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
); );
} }
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test] #[test]
fn pressing_o_toggles_settings_screen_flag() { fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app(); let mut app = headless_app();
@@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App {
fn settings_buttons_get_focusable_marker() { fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
// Open the panel. // Walk every tab: each rebuild must leave no bespoke
app.world_mut().resource_mut::<SettingsScreen>().0 = true; // `SettingsButton` (not `Done`, which is also a `ModalButton`)
app.update(); // without a `Focusable`.
// Two more ticks: the first runs `sync_settings_panel_visibility` let mut total_tagged = 0usize;
// and queues the spawn commands; the second flushes them and for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
// runs `attach_focusable_to_settings_buttons`. if i == 0 {
app.update(); open_settings_on(&mut app, tab);
app.update(); } else {
switch_settings_tab(&mut app, tab);
}
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
let untagged: Vec<&SettingsButton> = app let untagged: Vec<&SettingsButton> = app
.world_mut() .world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>() .query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world()) .iter(app.world())
.collect(); .collect();
assert!( assert!(
untagged.is_empty(), untagged.is_empty(),
"every bespoke Settings button must carry Focusable; missing: {:?}", "every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
untagged
); );
// And there must be at least one tagged `SettingsButton` so the total_tagged += app
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut() .world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>() .query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world()) .iter(app.world())
.count(); .count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!( assert!(
tagged_count >= 6, total_tagged >= 20,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}" "expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
); );
} }
@@ -290,14 +309,17 @@ fn settings_buttons_get_focusable_marker() {
fn settings_buttons_carry_tooltip() { fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
// Open the panel and let spawn + child-flush run. // Walk every tab: no bespoke `SettingsButton` (i.e. excluding
app.world_mut().resource_mut::<SettingsScreen>().0 = true; // `Done`, which is also a `ModalButton`) may be missing a
app.update(); // `Tooltip` on any of them.
app.update(); let mut total_tipped = 0usize;
app.update(); for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
let untipped: Vec<&SettingsButton> = app let untipped: Vec<&SettingsButton> = app
.world_mut() .world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>() .query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
@@ -305,26 +327,27 @@ fn settings_buttons_carry_tooltip() {
.collect(); .collect();
assert!( assert!(
untipped.is_empty(), untipped.is_empty(),
"every bespoke Settings button must carry Tooltip; missing: {:?}", "every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
untipped
); );
// And there must be at least 6 tipped buttons so the assertion total_tipped += app
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut() .world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>() .query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world()) .iter(app.world())
.count(); .count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!( assert!(
tipped_count >= 6, total_tipped >= 20,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}" "expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
); );
// Spot-check: with default (Local) settings the Connect button // The loop ends on the Account tab, where with default (Local)
// spawns. We verify its tooltip carries the canonical microcopy. // settings the Connect button spawns.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app let connect_tip = app
.world_mut() .world_mut()
.query::<(&SettingsButton, &Tooltip)>() .query::<(&SettingsButton, &Tooltip)>()
@@ -342,10 +365,8 @@ fn settings_buttons_carry_tooltip() {
fn settings_picker_rows_get_focus_row_marker() { fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus(); let mut app = headless_app_with_focus();
app.world_mut().resource_mut::<SettingsScreen>().0 = true; // Picker rows live on the Appearance tab.
app.update(); open_settings_on(&mut app, SettingsTab::Appearance);
app.update();
app.update();
// Two picker rows are spawned (card-back + background); each // Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker. // must carry the FocusRow marker.
@@ -360,6 +381,45 @@ fn settings_picker_rows_get_focus_row_marker() {
); );
} }
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has /// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a /// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the /// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
@@ -404,11 +464,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
); );
app.insert_resource(cache); app.insert_resource(cache);
// Open the panel and let the spawn + child-flush systems run. // Open the panel on the Appearance tab (the theme picker's home)
app.world_mut().resource_mut::<SettingsScreen>().0 = true; // and let the spawn + child-flush systems run.
app.update(); open_settings_on(&mut app, SettingsTab::Appearance);
app.update();
app.update();
// Find every ImageNode tagged with ThemeThumbnailMarker — the // Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly // theme picker chip for "test_theme" must contribute exactly
+174 -76
View File
@@ -45,13 +45,34 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32, scroll_offset: f32,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
theme_overrides_back: bool, theme_overrides_back: bool,
active_tab: SettingsTab,
) { ) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res); spawn_modal_header(card, "Settings", font_res);
// Scrollable bodycontains every section so tall content stays // Tab chips — one group of rows on screen at a time, no
// reachable on short windows. The Done button below stays fixed // scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
// outside the scroll so it's always one click away. card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for tab in SettingsTab::ALL {
tab_chip(row, tab, tab == active_tab, font_res);
}
});
// Scrollable body — most tabs fit without scrolling, but the
// Appearance pickers can still overflow short windows. The Done
// button below stays fixed outside the scroll so it's always one
// click away.
card.spawn(( card.spawn((
SettingsPanelScrollable, SettingsPanelScrollable,
SettingsScrollNode, SettingsScrollNode,
@@ -65,9 +86,80 @@ pub(super) fn spawn_settings_panel(
..default() ..default()
}, },
)) ))
.with_children(|body| { .with_children(|body| match active_tab {
// --- Audio --- SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
section_label(body, "Audio", font_res); SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
SettingsTab::Appearance => spawn_appearance_tab(
body,
settings,
unlocked_card_backs,
unlocked_backgrounds,
themes,
theme_overrides_back,
font_res,
),
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
});
// Done is the only action — primary so the player always knows
// how to leave the modal. `O` toggles it the same way.
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
SettingsButton::Done,
"Done",
Some("O"),
ButtonVariant::Primary,
font_res,
);
});
});
}
/// One Settings tab chip. The active chip is filled + bright; inactive
/// chips are quiet outlines.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
SettingsTabButton(tab),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(tab.label()),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
/// Audio tab: the two volume rows.
fn spawn_audio_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
volume_row( volume_row(
body, body,
"SFX Volume", "SFX Volume",
@@ -90,9 +182,14 @@ pub(super) fn spawn_settings_panel(
"Raise music and ambience volume.", "Raise music and ambience volume.",
font_res, font_res,
); );
}
// --- Gameplay --- /// Gameplay tab: rules and pacing.
section_label(body, "Gameplay", font_res); fn spawn_gameplay_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row( toggle_row(
body, body,
"Draw Mode", "Draw Mode",
@@ -121,21 +218,8 @@ pub(super) fn spawn_settings_panel(
"Cycle animation speed: Normal, Fast, Instant.", "Cycle animation speed: Normal, Fast, Instant.",
font_res, font_res,
); );
tooltip_delay_row( time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
body, replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
toggle_row( toggle_row(
body, body,
"Smart window size", "Smart window size",
@@ -147,9 +231,19 @@ pub(super) fn spawn_settings_panel(
window size always wins.", window size always wins.",
font_res, font_res,
); );
}
// --- Cosmetic --- /// Appearance tab: felt, deck art, backgrounds, and the theme system.
section_label(body, "Cosmetic", font_res); #[allow(clippy::too_many_arguments)]
fn spawn_appearance_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[ThemePickerEntry],
theme_overrides_back: bool,
font_res: Option<&FontResource>,
) {
toggle_row( toggle_row(
body, body,
"Theme", "Theme",
@@ -159,42 +253,6 @@ pub(super) fn spawn_settings_panel(
"Cycle felt color: Green, Blue, Dark.", "Cycle felt color: Green, Blue, Dark.",
font_res, font_res,
); );
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"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,
);
if theme_overrides_back { if theme_overrides_back {
// The active theme provides its own back; the legacy // The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its // picker has no visible effect, so we replace its
@@ -241,7 +299,62 @@ pub(super) fn spawn_settings_panel(
import_themes_row(body, font_res); import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res); theme_store_row(body, font_res);
}
/// Accessibility tab: perception and input assists, gathered from the
/// old Gameplay/Cosmetic sections so they're findable in one place.
fn spawn_accessibility_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"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,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
/// Account tab: privacy opt-in (when analytics is configured) and the
/// sync backend controls.
fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() { if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res); section_label(body, "Privacy", font_res);
@@ -259,21 +372,6 @@ pub(super) fn spawn_settings_panel(
// --- Sync --- // --- Sync ---
section_label(body, "Sync", font_res); section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res); sync_row(body, sync_status, &settings.sync_backend, font_res);
});
// Done is the only action — primary so the player always knows
// how to leave the modal. `O` toggles it the same way.
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
SettingsButton::Done,
"Done",
Some("O"),
ButtonVariant::Primary,
font_res,
);
});
});
} }
/// Section divider — small lavender label inside the scrollable body. /// Section divider — small lavender label inside the scrollable body.
+88 -20
View File
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility( pub(super) fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>, screen: Res<SettingsScreen>,
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>, panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>, other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>, scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
@@ -35,16 +36,53 @@ pub(super) fn sync_settings_panel_visibility(
} }
if screen.0 { if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() { if panels.is_empty() && other_modal_scrims.is_empty() {
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
scroll_pos.0,
active_tab.0,
);
}
} else {
// Save the current scroll offset before despawning the panel.
if let Ok(sp) = scroll_nodes.single() {
scroll_pos.0 = sp.0.y;
}
for entity in &panels {
commands.entity(entity).despawn();
}
}
}
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
/// back-override flag) and spawns the panel showing `active_tab`.
/// Shared by [`sync_settings_panel_visibility`] (open) and
/// [`rebuild_panel_on_tab_change`] (tab switch).
#[allow(clippy::too_many_arguments)]
fn build_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
font_res: Option<&FontResource>,
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
let status_label = sync_status.map_or_else( let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(), || "Status: local only".to_string(),
|s| sync_status_label(&s.0), |s| sync_status_label(&s.0),
); );
let unlocked_backs = progress let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
.as_ref() let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional) // Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to // thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when // know about the registry / cache shapes. Empty when
@@ -54,14 +92,12 @@ pub(super) fn sync_settings_panel_visibility(
// theme) leave `thumbnails: None` so the chip renders its // theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite. // plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref()
.map(|r| { .map(|r| {
r.iter() r.iter()
.map(|e| ThemePickerEntry { .map(|e| ThemePickerEntry {
id: e.id.clone(), id: e.id.clone(),
display_name: e.display_name.clone(), display_name: e.display_name.clone(),
thumbnails: theme_thumbs thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id)) .and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated()) .filter(|p| p.is_fully_populated())
.cloned(), .cloned(),
@@ -76,30 +112,62 @@ pub(super) fn sync_settings_panel_visibility(
// rather than letting the player click swatches that do // rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as // nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case. // "no override" in that case.
let theme_overrides_back = card_images let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
.as_ref()
.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel( spawn_settings_panel(
&mut commands, commands,
&settings.0, settings,
&status_label, &status_label,
unlocked_backs, unlocked_backs,
unlocked_bgs, unlocked_bgs,
&themes, &themes,
scroll_pos.0, scroll_offset,
font_res.as_deref(), font_res,
theme_overrides_back, theme_overrides_back,
active_tab,
); );
} }
} else {
// Save the current scroll offset before despawning the panel. /// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
if let Ok(sp) = scroll_nodes.single() { /// clicked). No-op while the panel is closed — the next open reads the
scroll_pos.0 = sp.0.y; /// resource directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn rebuild_panel_on_tab_change(
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
}
if panels.is_empty() {
return;
} }
for entity in &panels { for entity in &panels {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} // A fresh tab starts at the top; per-tab scroll memory isn't worth
// the bookkeeping now that no tab needs much scrolling.
scroll_pos.0 = 0.0;
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
0.0,
active_tab.0,
);
} }
/// Keeps the sync-status text node current while the panel is open. /// Keeps the sync-status text node current while the panel is open.