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>
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).
solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):
- Settings: Esc clears SettingsScreen, gated on being the topmost
modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)
Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.
Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>