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>
This commit is contained in:
funman300
2026-07-13 20:03:09 -07:00
parent d4d0bde0c0
commit 02bcc8b4af
4 changed files with 207 additions and 67 deletions
+26 -30
View File
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
spawn_modal_button, spawn_modal_header, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
Z_MODAL_PANEL, Z_PAUSE_DIALOG, Z_PAUSE_DIALOG,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -536,11 +536,6 @@ fn spawn_leaderboard_screen(
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_row = TextFont { let font_row = TextFont {
font: font_handle.clone(), font: font_handle.clone(),
font_size: TYPE_BODY, font_size: TYPE_BODY,
@@ -647,30 +642,31 @@ fn spawn_leaderboard_screen(
.with_children(|body| { .with_children(|body| {
match data { match data {
LeaderboardResource::Idle => { LeaderboardResource::Idle => {
body.spawn(( spawn_empty_state(
Text::new("Fetching\u{2026}"), body,
font_status.clone(), "\u{21BB}",
TextColor(STATE_INFO), "Fetching the leaderboard\u{2026}",
)); None,
font_res,
);
} }
LeaderboardResource::Error(_) => { LeaderboardResource::Error(_) => {
body.spawn(( spawn_empty_state(
Text::new("Couldn't reach the leaderboard. Try again later."), body,
font_status.clone(), "!",
TextColor(TEXT_SECONDARY), "Couldn't reach the leaderboard.",
)); Some("Try again later."),
font_res,
);
} }
LeaderboardResource::Loaded(rows) if rows.is_empty() => { LeaderboardResource::Loaded(rows) if rows.is_empty() => {
body.spawn(( spawn_empty_state(
Text::new("Be the first on the leaderboard."), body,
font_status.clone(), "\u{2660}",
TextColor(TEXT_PRIMARY), "Be the first on the leaderboard.",
)); Some("Win a game and opt in to appear here."),
body.spawn(( font_res,
Text::new("Win a game and opt in to appear here."), );
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
} }
LeaderboardResource::Loaded(rows) => { LeaderboardResource::Loaded(rows) => {
// Column headers // Column headers
+23 -17
View File
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, 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, 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. /// Bevy resource wrapping the current stats.
@@ -719,25 +719,17 @@ pub(crate) fn spawn_stats_body(
}, },
)) ))
.with_children(|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 // the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state. // than as broken state.
if is_first_launch { if is_first_launch {
body.spawn(( crate::ui_modal::spawn_empty_state(
Text::new("Play a game to start tracking stats."), body,
TextFont { "\u{2663}",
font_size: TYPE_CAPTION, "Play a game to start tracking stats.",
..default() None,
}, font_res,
TextColor(TEXT_SECONDARY), );
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
} }
// --- primary stat cells grid --- // --- primary stat cells grid ---
@@ -893,6 +885,20 @@ pub(crate) fn spawn_replays_body(
selected_index: usize, selected_index: usize,
font_res: Option<&FontResource>, 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_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont { let font_row = TextFont {
font: font_handle, font: font_handle,
+23 -17
View File
@@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource}; use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry}; use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
spawn_modal_button, spawn_modal_header, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, 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 { match catalog_state {
CatalogState::Idle | CatalogState::Loading => { CatalogState::Idle | CatalogState::Loading => {
card.spawn(( spawn_empty_state(
Text::new("Loading catalog…"), card,
body_font.clone(), "\u{21BB}",
TextColor(TEXT_SECONDARY), "Loading the catalog\u{2026}",
)); None,
font_res,
);
} }
CatalogState::Error(msg) => { CatalogState::Error(msg) => {
card.spawn(( spawn_empty_state(
Text::new(format!("Could not load the catalog: {msg}")), card,
body_font.clone(), "!",
TextColor(TEXT_SECONDARY), "Couldn't load the catalog.",
)); Some(msg.as_str()),
font_res,
);
} }
CatalogState::Loaded(entries) if entries.is_empty() => { CatalogState::Loaded(entries) if entries.is_empty() => {
card.spawn(( spawn_empty_state(
Text::new("The server has no themes yet."), card,
body_font.clone(), "#",
TextColor(TEXT_SECONDARY), "The server has no themes yet.",
)); Some("Themes dropped into the server's theme_store folder appear here."),
font_res,
);
} }
CatalogState::Loaded(entries) => { CatalogState::Loaded(entries) => {
for entry in entries { for entry in entries {
+135 -3
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, 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, 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, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
VAL_SPACE_4, VAL_SPACE_5, scaled_duration, 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))); 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. /// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`. /// 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" "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"
);
}
} }