feat(engine): replace docked touch bar with floating glass tab bar
Test / fmt (pull_request) Successful in 7s
Test / test (pull_request) Successful in 4m50s

Touch layout only (desktop bar unchanged): a floating glass pill
(Undo / Draw / Hint / Pause) plus a detached circular Menu button,
margin above the bottom edge on top of the safe-area inset. Draw is
the persistently expanded accent pill (icon + label); other buttons
are icon-only and slide their label out while pressed, snapping under
reduce-motion. Buttons keep the existing marker components so click
handlers, tooltips, focus ring, and chrome toggle work unchanged.
Icons: None degrades to text-fallback labels (MinimalPlugins tests /
SVG regression), keeping the bar fully usable.

Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the
legacy touch bar spawn path and primary-button metrics are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-15 13:57:21 -07:00
parent 7c2daa1e32
commit c8fc3e7a09
8 changed files with 598 additions and 135 deletions
+87 -7
View File
@@ -907,6 +907,10 @@ fn hud_focus_only_engages_when_button_hovered() {
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
/// buttons the tested spawn function creates — `headless_app` would
/// pre-spawn the platform-default bar and pollute the counts.
///
/// The touch branch spawns the glass tab bar with `icons: None`
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
/// text-fallback path — same markers, labels pinned expanded.
fn bar_only_app(touch: bool) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -914,13 +918,13 @@ fn bar_only_app(touch: bool) -> App {
let font = TextFont::default();
let world = app.world_mut();
let mut commands = world.commands();
commands.spawn(Node::default()).with_children(|row| {
if touch {
spawn_touch_action_bar(row, &font);
} else {
if touch {
spawn_glass_tab_bar(&mut commands, &font, None);
} else {
commands.spawn(Node::default()).with_children(|row| {
spawn_desktop_action_bar(row, &font);
}
});
});
}
app.update();
app
}
@@ -933,7 +937,7 @@ fn count_buttons<C: Component>(app: &mut App) -> usize {
}
#[test]
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
let mut app = bar_only_app(true);
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
@@ -949,6 +953,82 @@ fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
}
/// The glass bar floats: its container must carry a bottom margin (not be
/// docked at 0) both before and after safe-area insets arrive.
#[test]
fn glass_tab_bar_floats_above_bottom_edge() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
let (node, anchor) = q.single(world).expect("glass bar container exists");
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
}
/// Draw is the persistently-expanded active pill (user decision
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
/// text fallback, so instead assert on the *persistent* flag's effect:
/// all wrappers spawn at full reveal and target full reveal.
#[test]
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query::<&TabLabelWrap>();
let wraps: Vec<_> = q.iter(world).collect();
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
for wrap in wraps {
assert_eq!(wrap.progress, 1.0);
assert_eq!(wrap.target, 1.0);
}
}
/// Focus order must match visual reading order: pill buttons left to
/// right, then the detached Menu circle.
#[test]
fn glass_tab_bar_focus_order_reads_left_to_right() {
let mut app = bar_only_app(true);
for (f, expected) in [
(focusable_for::<UndoButton>(&mut app), 0),
(focusable_for::<DrawButton>(&mut app), 1),
(focusable_for::<HintButton>(&mut app), 2),
(focusable_for::<PauseButton>(&mut app), 3),
(focusable_for::<MenuButton>(&mut app), 4),
] {
assert_eq!(f.group, FocusGroup::Hud);
assert_eq!(f.order, expected);
}
}
/// `expansion_width` is the single source of truth for the label reveal:
/// zero when collapsed, lead + monospace advance when expanded, linear
/// in between.
#[test]
fn expansion_width_is_linear_in_progress() {
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
let full = expansion_width(4, 16.0, 1.0);
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
let half = expansion_width(4, 16.0, 0.5);
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
// Out-of-range progress clamps rather than extrapolating.
assert_eq!(expansion_width(4, 16.0, 1.5), full);
}
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
/// and snap instantly under reduce-motion.
#[test]
fn step_expansion_tweens_and_respects_reduce_motion() {
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
assert!(one_frame > 0.0 && one_frame < 0.2);
// A huge dt clamps at the target instead of overshooting.
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
// Collapse works symmetrically.
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
// Reduce-motion snaps regardless of dt.
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
}
#[test]
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
let mut app = bar_only_app(false);