diff --git a/docs/ui-redesign-2026-07.md b/docs/ui-redesign-2026-07.md new file mode 100644 index 0000000..c854fc8 --- /dev/null +++ b/docs/ui-redesign-2026-07.md @@ -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 | 1–2 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 | 1–2 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 | Low–Medium | 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? diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index d2b2af4..f69c679 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -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>, + mut active: ResMut, +) { + 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, /// and Delete Account. Split from `handle_settings_buttons` to stay within /// Bevy's 16-parameter system limit. diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index a5986c1..5a87c2e 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -170,6 +170,51 @@ struct SmartDefaultSizeText; #[derive(Component, Debug)] 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. #[derive(Component, Debug)] struct SettingsPanelScrollable; @@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin { .insert_resource(SettingsStoragePath(self.storage_path.clone())) .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_message::() .add_message::() @@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin { app.add_systems( Update, ( - sync_settings_panel_visibility, + // 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, + handle_tab_buttons, + rebuild_panel_on_tab_change, + ) + .chain(), handle_settings_buttons, handle_sync_buttons, #[cfg(not(target_arch = "wasm32"))] diff --git a/solitaire_engine/src/settings_plugin/tests.rs b/solitaire_engine/src/settings_plugin/tests.rs index e9543dc..e6b7173 100644 --- a/solitaire_engine/src/settings_plugin/tests.rs +++ b/solitaire_engine/src/settings_plugin/tests.rs @@ -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::().0 = tab; + app.world_mut().resource_mut::().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::().0 = tab; + app.update(); + app.update(); + app.update(); +} + #[test] fn pressing_o_toggles_settings_screen_flag() { let mut app = headless_app(); @@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App { fn settings_buttons_get_focusable_marker() { let mut app = headless_app_with_focus(); - // Open the panel. - app.world_mut().resource_mut::().0 = true; - app.update(); - // Two more ticks: the first runs `sync_settings_panel_visibility` - // and queues the spawn commands; the second flushes them and - // runs `attach_focusable_to_settings_buttons`. - app.update(); - app.update(); + // Walk every tab: each rebuild must leave no bespoke + // `SettingsButton` (not `Done`, which is also a `ModalButton`) + // without a `Focusable`. + let mut total_tagged = 0usize; + 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); + } - // Every bespoke `SettingsButton` (not `Done`, which is also a - // `ModalButton`) must carry a `Focusable`. - let untagged: Vec<&SettingsButton> = app - .world_mut() - .query_filtered::<&SettingsButton, (With