feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5)

In --demo mode, an otherwise-invisible key (Tab, Enter, arrows,
Ctrl-O, …) raises a transient [LABEL] badge — a floating
black-on-yellow box inset at the output panel's bottom-right. Set in
App::update before the modal gate (so it shows over the load picker,
the #24 cast); pure demo_badge_label maps the key set. The runtime
expires it on a ~1.5s timer via a new nearest_deadline helper that
extends the existing time-boxed-recv arm condition without disturbing
the ADR-0027 indicator debounce. New App.last_output_area lets the
top-level draw anchor the overlay; overlay colours centralised in
theme.rs.

Tier 1 (label fn, badge set/seq, over-modal), Tier 2 (dark/light
snapshots, black-on-yellow style, too-small clamp), runtime unit
(nearest_deadline). Phase B of ADR-0047; captions land in C.
This commit is contained in:
claude@clouddev1
2026-06-11 07:02:23 +00:00
parent f879d54721
commit 2584e76b22
6 changed files with 462 additions and 22 deletions
+142
View File
@@ -113,6 +113,55 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
if let Some(modal) = app.modal.as_ref() {
render_modal(modal, theme, frame, area);
}
// ADR-0047 D4: the demo overlays draw last of all — over modals — so
// a keystroke badge (and, in Phase C, a step caption) stays visible
// while the load picker (the #24 cast) or any modal is up.
if app.demo_mode {
render_demo_overlays(app, frame);
}
}
/// Draw the demonstration-mode overlays anchored to the output panel's
/// inner bottom-right corner (ADR-0047 D4). Phase B renders the
/// keystroke badge; the step-caption box joins it in Phase C.
fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
let area = app.last_output_area;
if area.width == 0 || area.height == 0 {
return; // not measured yet
}
if let Some(label) = app.demo_badge {
render_badge_box(label, area, frame);
}
}
/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset
/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). Skipped
/// rather than overflowing if the output area is too small to host it.
fn render_badge_box(label: &str, area: Rect, frame: &mut Frame<'_>) {
let style = Style::default()
.bg(crate::theme::DEMO_OVERLAY_BG)
.fg(crate::theme::DEMO_OVERLAY_FG)
.add_modifier(Modifier::BOLD);
// ` [LABEL] ` (one pad each side) inside a rounded border.
let box_w = label.chars().count() as u16 + 4;
let box_h = 3;
if box_w + 1 > area.width || box_h + 1 > area.height {
return;
}
let rect = Rect {
x: area.x + area.width - box_w - 1,
y: area.y + area.height - box_h - 1,
width: box_w,
height: box_h,
};
frame.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(style);
let para = Paragraph::new(format!(" {label} ")).style(style).block(block);
frame.render_widget(para, rect);
}
/// Width (columns) of the navigation-mode expanded sidebar overlay
@@ -933,6 +982,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
// ADR-0044 §3: record the panel width so a later `show relationship`
// diagram (rendered App-side) can choose side-by-side vs vertical.
app.last_output_width = inner.width;
// ADR-0047 D4: record the full inner area so the top-level draw can
// anchor the demo overlays to the output panel's bottom-right corner.
app.last_output_area = inner;
let lines: Vec<Line<'_>> = app
.output
@@ -2966,4 +3018,94 @@ mod tests {
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot);
}
// ---- ADR-0047 (issue #22): demo-mode keystroke badge ----
/// Render to a `TestBackend` buffer (for cell-level style checks the
/// text-only `render_to_string` cannot make).
fn render_to_buffer(
app: &mut App,
theme: &Theme,
width: u16,
height: u16,
) -> ratatui::buffer::Buffer {
if app.project_name.is_none() {
app.project_name = Some("Term Planner".to_string());
}
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal.draw(|f| render(app, theme, f)).expect("draw frame");
terminal.backend().buffer().clone()
}
#[test]
fn demo_badge_box_renders_at_output_bottom_right() {
// At the 90×26 cast geometry the sidebar is hidden and the badge
// box sits inset in the output panel's bottom-right corner.
let mut app = App::new();
app.demo_mode = true;
app.demo_badge = Some("[TAB]");
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 90, 26);
insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot);
}
#[test]
fn demo_badge_box_renders_in_light_theme() {
let mut app = App::new();
app.demo_mode = true;
app.demo_badge = Some("[ENTER]");
let theme = Theme::light();
let snapshot = render_to_string(&mut app, &theme, 90, 26);
insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot);
}
#[test]
fn demo_badge_box_is_black_on_yellow() {
let mut app = App::new();
app.demo_mode = true;
app.demo_badge = Some("[TAB]");
let theme = Theme::dark();
let buffer = render_to_buffer(&mut app, &theme, 90, 26);
// Collect the badge cells (the only ones painted with the fixed
// overlay background) and confirm the high-contrast pairing.
let mut badge_cells = 0;
let mut row_text: std::collections::BTreeMap<u16, String> = Default::default();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
if cell.bg == crate::theme::DEMO_OVERLAY_BG {
badge_cells += 1;
assert_eq!(
cell.fg,
crate::theme::DEMO_OVERLAY_FG,
"badge cell at ({x},{y}) must be black-on-yellow"
);
row_text.entry(y).or_default().push_str(cell.symbol());
}
}
}
assert!(badge_cells > 0, "expected a yellow badge box to be drawn");
// The label appears on the box's middle (text) row.
assert!(
row_text.values().any(|line| line.contains("[TAB]")),
"badge text not found among styled rows: {row_text:?}"
);
}
#[test]
fn demo_badge_box_skipped_when_area_too_small() {
// ADR-0047 D4 clamp guard: a box that cannot fit the given area
// is not drawn rather than overflowing.
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
.draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), f))
.expect("draw frame");
let buffer = terminal.backend().buffer();
let drew_badge = (0..buffer.area.height).any(|y| {
(0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG)
});
assert!(!drew_badge, "badge must be skipped when it cannot fit");
}
}