feat(engine): port toasts to the Terminal design-system spec
Toasts now follow `docs/ui-mockups/design-system.md`: - Bottom-anchored absolute position (was top / mid-screen) - Opaque BG_ELEVATED fill (was translucent black-at-alpha) - 1px accent border keyed off a new ToastVariant enum - TYPE_BODY_LG caption (was 22 / 32 px literals) - RADIUS_MD corners ToastVariant exposes Info / Warning / Error / Celebration, each mapped to its design-system token via border_color(). Variants are threaded through every spawn_toast call site: - Achievement / Level-up / XP / Daily / Weekly / Challenge → Celebration - Goal-announcement / Time-attack / Settings volume / Auto-complete → Info Queued banner and fire-and-forget toasts use slightly different bottom anchors (6% vs. 14%) so a celebration toast spawned in the same frame as a queued info banner layers above it instead of overlapping. Two new tests pin variant→border mapping to the design tokens and require all four borders to be visually distinct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,9 @@ use crate::progress_plugin::LevelUpEvent;
|
|||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
|
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
|
||||||
MOTION_SLIDE_SECS, TEXT_PRIMARY, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
|
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
|
||||||
|
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
|
||||||
};
|
};
|
||||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||||
|
|
||||||
@@ -339,6 +340,7 @@ fn handle_achievement_toast(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Achievement: {}", display_name_for(&ev.0.id)),
|
format!("Achievement: {}", display_name_for(&ev.0.id)),
|
||||||
ACHIEVEMENT_TOAST_SECS,
|
ACHIEVEMENT_TOAST_SECS,
|
||||||
|
ToastVariant::Celebration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +351,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelU
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Level Up! → {}", ev.new_level),
|
format!("Level Up! → {}", ev.new_level),
|
||||||
LEVELUP_TOAST_SECS,
|
LEVELUP_TOAST_SECS,
|
||||||
|
ToastVariant::Celebration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +361,12 @@ fn handle_daily_goal_announcement_toast(
|
|||||||
mut events: MessageReader<DailyGoalAnnouncementEvent>,
|
mut events: MessageReader<DailyGoalAnnouncementEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("Goal: {}", ev.0),
|
||||||
|
DAILY_TOAST_SECS,
|
||||||
|
ToastVariant::Info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +379,7 @@ fn handle_daily_toast(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
|
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
|
||||||
DAILY_TOAST_SECS,
|
DAILY_TOAST_SECS,
|
||||||
|
ToastVariant::Celebration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +393,7 @@ fn handle_weekly_toast(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Weekly Goal: {}", ev.description),
|
format!("Weekly Goal: {}", ev.description),
|
||||||
WEEKLY_TOAST_SECS,
|
WEEKLY_TOAST_SECS,
|
||||||
|
ToastVariant::Celebration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,6 +407,7 @@ fn handle_time_attack_toast(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
|
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
|
||||||
TIME_ATTACK_TOAST_SECS,
|
TIME_ATTACK_TOAST_SECS,
|
||||||
|
ToastVariant::Info,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,6 +421,7 @@ fn handle_challenge_toast(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
|
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
|
||||||
CHALLENGE_TOAST_SECS,
|
CHALLENGE_TOAST_SECS,
|
||||||
|
ToastVariant::Celebration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,11 +441,21 @@ fn handle_settings_toast(
|
|||||||
*last_music = Some(music);
|
*last_music = Some(music);
|
||||||
if sfx_changed {
|
if sfx_changed {
|
||||||
let pct = (sfx * 100.0).round() as i32;
|
let pct = (sfx * 100.0).round() as i32;
|
||||||
spawn_toast(&mut commands, format!("SFX: {pct}%"), VOLUME_TOAST_SECS);
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("SFX: {pct}%"),
|
||||||
|
VOLUME_TOAST_SECS,
|
||||||
|
ToastVariant::Info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if music_changed {
|
if music_changed {
|
||||||
let pct = (music * 100.0).round() as i32;
|
let pct = (music * 100.0).round() as i32;
|
||||||
spawn_toast(&mut commands, format!("Music: {pct}%"), VOLUME_TOAST_SECS);
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("Music: {pct}%"),
|
||||||
|
VOLUME_TOAST_SECS,
|
||||||
|
ToastVariant::Info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +471,12 @@ fn handle_auto_complete_toast(
|
|||||||
if s.active {
|
if s.active {
|
||||||
if !*shown {
|
if !*shown {
|
||||||
*shown = true;
|
*shown = true;
|
||||||
spawn_toast(&mut commands, "Auto-completing…".to_string(), 2.0);
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
"Auto-completing…".to_string(),
|
||||||
|
2.0,
|
||||||
|
ToastVariant::Info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
*shown = false;
|
*shown = false;
|
||||||
@@ -513,37 +540,72 @@ fn drive_toast_display(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
|
/// Visual variant of a toast — drives the 1px border accent per the
|
||||||
|
/// design-system toast spec
|
||||||
|
/// (`docs/ui-mockups/design-system.md` → "Toasts").
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToastVariant {
|
||||||
|
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||||
|
/// settings volume notifications, and the auto-complete announcement.
|
||||||
|
Info,
|
||||||
|
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||||
|
/// event; kept so future warning-flavoured toasts have a slot.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Warning,
|
||||||
|
/// Failure / rejected action — pink border. Currently unused; kept so
|
||||||
|
/// future error-flavoured toasts have a slot.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Error,
|
||||||
|
/// Reward / milestone — lavender border. Used for XP awards,
|
||||||
|
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
|
||||||
|
Celebration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToastVariant {
|
||||||
|
/// Returns the 1px border accent for this variant per the design
|
||||||
|
/// system. Single source of truth — `spawn_toast` and
|
||||||
|
/// `spawn_queued_toast` both consume it so a future palette swap
|
||||||
|
/// only has to touch the token, never every call site.
|
||||||
|
fn border_color(self) -> Color {
|
||||||
|
match self {
|
||||||
|
ToastVariant::Info => STATE_INFO,
|
||||||
|
ToastVariant::Warning => STATE_WARNING,
|
||||||
|
ToastVariant::Error => STATE_DANGER,
|
||||||
|
ToastVariant::Celebration => ACCENT_SECONDARY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
||||||
|
///
|
||||||
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
commands
|
spawn_toast_node(
|
||||||
.spawn((
|
commands,
|
||||||
ToastEntity,
|
ToastEntity,
|
||||||
Node {
|
message,
|
||||||
position_type: PositionType::Absolute,
|
ToastVariant::Info,
|
||||||
left: Val::Percent(15.0),
|
// Slightly taller anchor than the immediate-fire path so a
|
||||||
top: Val::Percent(8.0),
|
// queued info banner doesn't collide with a celebration toast
|
||||||
width: Val::Percent(70.0),
|
// fired in the same frame.
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
Val::Percent(6.0),
|
||||||
justify_content: JustifyContent::Center,
|
Val::Percent(15.0),
|
||||||
align_items: AlignItems::Center,
|
Val::Percent(70.0),
|
||||||
..default()
|
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||||
},
|
)
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
|
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new(message),
|
|
||||||
TextFont { font_size: 22.0, ..default() },
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
})
|
|
||||||
.id()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("+{} XP", ev.amount),
|
||||||
|
3.0,
|
||||||
|
ToastVariant::Celebration,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,33 +631,88 @@ fn tick_toasts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
|
/// Spawns a bottom-anchored fire-and-forget toast that despawns after
|
||||||
|
/// `duration_secs`. The `variant` selects the 1px accent border color
|
||||||
|
/// per the design-system toast spec.
|
||||||
|
fn spawn_toast(
|
||||||
|
commands: &mut Commands,
|
||||||
|
message: String,
|
||||||
|
duration_secs: f32,
|
||||||
|
variant: ToastVariant,
|
||||||
|
) {
|
||||||
|
spawn_toast_node(
|
||||||
|
commands,
|
||||||
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
|
message,
|
||||||
|
variant,
|
||||||
|
// Sits above the queued banner so a celebration toast spawned
|
||||||
|
// alongside a queued info message remains readable.
|
||||||
|
Val::Percent(14.0),
|
||||||
|
Val::Percent(25.0),
|
||||||
|
Val::Percent(50.0),
|
||||||
|
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common toast-spawn primitive used by both the queued and the
|
||||||
|
/// fire-and-forget paths. Centralizes the design-system contract so a
|
||||||
|
/// future spec change (e.g. a different border thickness) is a
|
||||||
|
/// one-line edit.
|
||||||
|
///
|
||||||
|
/// The Terminal toast spec from `design-system.md`:
|
||||||
|
/// - Opaque [`BG_ELEVATED`] fill (no translucent dim).
|
||||||
|
/// - 1px border in the variant's accent color.
|
||||||
|
/// - [`TYPE_BODY_LG`] (18px) `TEXT_PRIMARY` caption — the spec calls
|
||||||
|
/// for 16px, but the engine type scale only carries 14/18/26/40/...
|
||||||
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
|
/// tested in `ui_theme::tests`.
|
||||||
|
/// - [`RADIUS_MD`] corners.
|
||||||
|
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
||||||
|
/// queued and immediate paths so they layer instead of overlap.
|
||||||
|
// The 8-argument signature is intentional — these are the per-toast
|
||||||
|
// layout values that genuinely differ between the queued and fire-and-
|
||||||
|
// forget call sites. A struct wrapper would just rename the same data.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_toast_node<B: Bundle>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
bundle: B,
|
||||||
|
message: String,
|
||||||
|
variant: ToastVariant,
|
||||||
|
bottom_pct: Val,
|
||||||
|
left_pct: Val,
|
||||||
|
width_pct: Val,
|
||||||
|
padding: UiRect,
|
||||||
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ToastOverlay,
|
bundle,
|
||||||
ToastTimer(duration_secs),
|
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Percent(25.0),
|
left: left_pct,
|
||||||
top: Val::Percent(42.0),
|
bottom: bottom_pct,
|
||||||
width: Val::Percent(50.0),
|
width: width_pct,
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
padding,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(variant.border_color()),
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new(message),
|
Text::new(message),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: 32.0,
|
font_size: TYPE_BODY_LG,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(ACCENT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
});
|
})
|
||||||
|
.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -703,6 +820,41 @@ mod tests {
|
|||||||
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
|
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pin every `ToastVariant` to its design-system border colour.
|
||||||
|
/// A future palette swap that touches `STATE_INFO`, `STATE_WARNING`,
|
||||||
|
/// `STATE_DANGER`, or `ACCENT_SECONDARY` flows through this mapping
|
||||||
|
/// automatically; this test guards against accidental remappings.
|
||||||
|
#[test]
|
||||||
|
fn toast_variant_border_colors_match_design_tokens() {
|
||||||
|
assert_eq!(ToastVariant::Info.border_color(), STATE_INFO);
|
||||||
|
assert_eq!(ToastVariant::Warning.border_color(), STATE_WARNING);
|
||||||
|
assert_eq!(ToastVariant::Error.border_color(), STATE_DANGER);
|
||||||
|
assert_eq!(ToastVariant::Celebration.border_color(), ACCENT_SECONDARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `ToastVariant` resolves to a unique border colour so a
|
||||||
|
/// careless rebinding (e.g. accidentally setting `Warning` to the
|
||||||
|
/// same hue as `Info`) fails loudly. Pure check — does not run a
|
||||||
|
/// Bevy app.
|
||||||
|
#[test]
|
||||||
|
fn toast_variant_border_colors_are_distinct() {
|
||||||
|
let colors = [
|
||||||
|
ToastVariant::Info.border_color(),
|
||||||
|
ToastVariant::Warning.border_color(),
|
||||||
|
ToastVariant::Error.border_color(),
|
||||||
|
ToastVariant::Celebration.border_color(),
|
||||||
|
];
|
||||||
|
for i in 0..colors.len() {
|
||||||
|
for j in (i + 1)..colors.len() {
|
||||||
|
assert_ne!(
|
||||||
|
format!("{:?}", colors[i]),
|
||||||
|
format!("{:?}", colors[j]),
|
||||||
|
"variants {i} and {j} resolved to the same border colour",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anim_speed_instant_is_zero() {
|
fn anim_speed_instant_is_zero() {
|
||||||
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
|
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user