feat(engine): convert LeaderboardScreen to modal scaffold + Done button

Phase 3 step 5e of the UX overhaul. Wraps the leaderboard list inside
the standard ui_modal scaffold; converts the Opt In / Opt Out buttons
to use spawn_modal_button (so they pick up the shared hover / press
paint system); replaces "Press L to close" prose with a primary Done
button.

Changes:
- spawn_leaderboard_screen now goes through spawn_modal(LeaderboardScreen,
  Z_MODAL_PANEL, ...). The bespoke 0.82-alpha scrim and hand-rolled
  card surface are gone — same visual contract as every other overlay.
- Opt In becomes a Secondary modal button; Opt Out becomes Tertiary.
  Both fire the same fetch tasks they did before.
- Header / data cells switch to ui_theme tokens. The top-3 podium
  effect now uses ACCENT_PRIMARY (yellow) for #1 and TEXT_PRIMARY for
  #2/#3 instead of metallic-coloured srgb literals; #4+ use
  TEXT_SECONDARY.
- Header-cell and data-cell helpers now take a `&TextFont` so all
  three sizes (HEADLINE / BODY_LG / BODY / CAPTION) come from the
  shared scale instead of inline 13px / 15px sizes.
- "Fetching\u{2026}" loading state uses STATE_INFO; empty-state copy
  uses TEXT_SECONDARY.
- handle_leaderboard_close_button is the click counterpart to L; it
  also sets ClosedThisFrame so update_leaderboard_panel doesn't
  immediately respawn the modal when a fetch completes in the same
  frame.

The sort-by-score code is replaced with `sort_by_key(Reverse(...))`
to satisfy clippy's unnecessary_sort_by lint that surfaced once the
file was otherwise warning-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:40:59 +00:00
parent 99064ce808
commit 37681cf33e
+140 -127
View File
@@ -15,8 +15,16 @@ use solitaire_data::settings::SyncBackend;
use solitaire_sync::LeaderboardEntry;
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Resources
@@ -79,6 +87,7 @@ impl Plugin for LeaderboardPlugin {
(
reset_closed_flag,
toggle_leaderboard_screen,
handle_leaderboard_close_button,
poll_leaderboard_fetch,
update_leaderboard_panel,
handle_opt_in_button,
@@ -111,6 +120,7 @@ fn toggle_leaderboard_screen(
screens: Query<Entity, With<LeaderboardScreen>>,
data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>,
font_res: Option<Res<FontResource>>,
mut task_res: ResMut<LeaderboardFetchTask>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
@@ -125,7 +135,7 @@ fn toggle_leaderboard_screen(
}
// Spawn the panel immediately with whatever data we have (may be None).
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
// Start a background fetch if not already in flight.
if task_res.0.is_none()
@@ -157,6 +167,7 @@ fn update_leaderboard_panel(
mut result_res: ResMut<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>,
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
let Some(result) = result_res.0.take() else { return };
@@ -180,7 +191,23 @@ fn update_leaderboard_panel(
}
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay.
fn handle_leaderboard_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
closed_flag.0 = true;
}
}
@@ -283,153 +310,117 @@ fn poll_opt_out_task(
// UI construction
// ---------------------------------------------------------------------------
fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[LeaderboardEntry]>) {
commands
.spawn((
LeaderboardScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
/// Marker on the "Done" button inside the Leaderboard modal.
#[derive(Component, Debug)]
pub struct LeaderboardCloseButton;
fn spawn_leaderboard_screen(
commands: &mut Commands,
entries: Option<&[LeaderboardEntry]>,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control.
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_caption = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
ZIndex(210),
))
.with_children(|root| {
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(8.0),
min_width: Val::Px(420.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
};
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
))
.with_children(|card| {
// Header
};
let font_row = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_header = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
card.spawn((
Text::new("Leaderboard"),
TextFont { font_size: 26.0, ..default() },
TextColor(Color::WHITE),
));
card.spawn((
Text::new("Press L to close • Opt In / Opt Out to control your visibility"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
// Separator
// Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| {
spawn_modal_button(
row,
LeaderboardOptInButton,
"Opt In",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
row,
LeaderboardOptOutButton,
"Opt Out",
None,
ButtonVariant::Tertiary,
font_res,
);
});
// Subtle separator between the controls and the data area.
card.spawn((
Node {
height: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BackgroundColor(BORDER_SUBTLE),
));
// Opt-in / Opt-out buttons row
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(10.0),
margin: UiRect::bottom(Val::Px(8.0)),
..default()
})
.with_children(|row| {
row.spawn((
LeaderboardOptInButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
))
.with_children(|b| {
b.spawn((
Text::new("Opt In"),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::WHITE),
));
});
row.spawn((
LeaderboardOptOutButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
))
.with_children(|b| {
b.spawn((
Text::new("Opt Out"),
TextFont { font_size: 15.0, ..default() },
TextColor(Color::WHITE),
));
});
});
match entries {
None => {
// Fetch in progress
card.spawn((
Text::new("Fetching…"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.70)),
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
Some([]) => {
card.spawn((
Text::new("No entries yet sync and opt in to appear here."),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.55, 0.55, 0.60)),
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
Some(rows) => {
// Column headers
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
margin: UiRect::bottom(Val::Px(4.0)),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
header_cell(row, "#", 30.0);
header_cell(row, "Player", 160.0);
header_cell(row, "Best Score", 100.0);
header_cell(row, "Fastest Win", 110.0);
header_cell(row, "#", 30.0, &font_header);
header_cell(row, "Player", 160.0, &font_header);
header_cell(row, "Best Score", 100.0, &font_header);
header_cell(row, "Fastest Win", 110.0, &font_header);
});
// Data rows (top 10)
let mut sorted = rows.to_vec();
sorted.sort_by(|a, b| {
b.best_score
.unwrap_or(0)
.cmp(&a.best_score.unwrap_or(0))
});
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
for (i, entry) in sorted.iter().take(10).enumerate() {
// Top three get accent treatments to highlight the
// podium without leaning on hand-picked metallic
// colours that sit outside the token system.
let rank_color = match i {
0 => Color::srgb(1.0, 0.84, 0.0),
1 => Color::srgb(0.75, 0.75, 0.75),
2 => Color::srgb(0.80, 0.50, 0.20),
_ => Color::srgb(0.80, 0.80, 0.80),
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
@@ -443,37 +434,59 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color);
data_cell(row, &entry.display_name, 160.0, Color::WHITE);
data_cell(row, &score_str, 100.0, Color::WHITE);
data_cell(row, &time_str, 110.0, Color::WHITE);
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
});
}
}
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
LeaderboardCloseButton,
"Done",
Some("L"),
ButtonVariant::Primary,
font_res,
);
});
});
}
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32) {
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 13.0, ..default() },
TextColor(Color::srgb(0.55, 0.75, 0.55)),
Node { width: Val::Px(width), ..default() },
font.clone(),
TextColor(TEXT_SECONDARY),
Node {
width: Val::Px(width),
..default()
},
));
}
fn data_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, color: Color) {
fn data_cell(
parent: &mut ChildSpawnerCommands,
text: &str,
width: f32,
color: Color,
font: &TextFont,
) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 15.0, ..default() },
font.clone(),
TextColor(color),
Node { width: Val::Px(width), ..default() },
Node {
width: Val::Px(width),
..default()
},
));
}