crates/gunnar-ui-web/src/ui.rs
//! The console's shared pieces.
//!
//! Every status in this console maps to one of four words, and the word is
//! always on screen next to the colour. Colour alone is the failure mode the
//! whole palette was built to avoid.
use leptos::prelude::*;
use crate::api::Fail;
/// The four states anything in this console can be in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
/// Working.
Pass,
/// Working, but someone should look.
Warn,
/// Not working.
Bad,
/// Changing right now.
Live,
/// Not a status at all.
Plain,
}
impl Tone {
fn class(self) -> &'static str {
match self {
Tone::Pass => "badge pass",
Tone::Warn => "badge warn",
Tone::Bad => "badge bad",
Tone::Live => "badge live",
Tone::Plain => "badge plain",
}
}
}
/// A small labelled state marker.
#[component]
pub fn Badge(tone: Tone, #[prop(into)] label: String) -> impl IntoView {
view! { <span class=tone.class()>{label}</span> }
}
/// The heading block at the top of a page.
///
/// The title is generic over [`IntoView`] so a page whose heading follows its
/// route parameters can pass a closure. Taking a plain `String` meant every such
/// page wrote the header out by hand instead, and three copies of a header is
/// how they stop looking alike.
///
/// **A title and nothing explaining it** (owner, 2026-10-02): there is no
/// subtitle slot. `line` is a second line of DATA — Overview's server key is
/// the one page that has one — and never a sentence about the page
/// (`headers::tests::no_page_header_carries_an_explanatory_subtitle`).
#[component]
pub fn PageHeader<T: IntoView + 'static>(
title: T,
#[prop(optional, into)] line: Option<AnyView>,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
view! {
<header class="page-header">
<div>
<h1>{title}</h1>
{line.map(|line| view! { <p class="page-line">{line}</p> })}
</div>
{children.map(|children| view! { <div class="actions">{children()}</div> })}
</header>
}
}
/// A bordered section with a heading.
#[component]
pub fn Panel(
#[prop(into)] title: String,
#[prop(optional)] actions: Option<Children>,
children: Children,
) -> impl IntoView {
view! {
<section class="panel">
<header>
<h2>{title}</h2>
{actions.map(|actions| view! { <div class="actions">{actions()}</div> })}
</header>
{children()}
</section>
}
}
/// A feature gunnar declares but does not implement.
///
/// Shown rather than hidden, because an operator who cannot find branch
/// protection in the console will go looking for it in gunnar, and the honest
/// answer is that the protocol has it and the server refuses it.
#[component]
pub fn DisabledPanel(#[prop(into)] title: String, #[prop(into)] because: String) -> impl IntoView {
view! {
<section class="panel disabled">
<header>
<h2>{title}</h2>
<div class="actions"><Badge tone=Tone::Plain label="not available"/></div>
</header>
<div class="panel-body"><p>{because}</p></div>
</section>
}
}
/// One number with its name.
#[component]
pub fn Metric(
#[prop(into)] label: String,
#[prop(into)] value: String,
#[prop(into, optional)] unit: String,
) -> impl IntoView {
view! {
<div class="metric">
<dt>{label}</dt>
<dd>
{value}
// A space before the unit, or "0 B" and "rickard" render glued
// ("0 Brickard", test2 2026-10-12).
{(!unit.is_empty()).then(|| view! { " " <small>{unit}</small> })}
</dd>
</div>
}
}
/// The shape of an answer that has not arrived.
#[component]
pub fn Loading(#[prop(default = 3)] rows: usize) -> impl IntoView {
view! {
<div class="panel-body" aria-busy="true" aria-live="polite">
<span class="vh">"Loading …"</span>
{(0..rows)
.map(|i| {
let width = match i % 3 {
0 => "skeleton w-70",
1 => "skeleton",
_ => "skeleton w-40",
};
view! { <div class=width></div> }
})
.collect_view()}
</div>
}
}
/// Nothing to show, and what to do about it — in ONE line (`detail`), with
/// the rest behind a "?" (`more`), as every page's text is (`crate::hint`).
#[component]
pub fn Empty(
#[prop(into)] title: String,
#[prop(into)] detail: String,
#[prop(into, optional)] more: String,
) -> impl IntoView {
let more = more.trim().to_owned();
view! {
<div class="empty">
<h3>{title}</h3>
<div class="hint-line">
<p>{detail}</p>
{(!more.is_empty()).then(|| view! {
<details class="why">
<summary title="Why?" aria-label="Why?">"?"</summary>
<p>{more}</p>
</details>
})}
</div>
</div>
}
}
/// A refusal, with the server's own words under the console's summary.
#[component]
pub fn FailBox(fail: Fail, #[prop(optional)] retry: Option<Callback<()>>) -> impl IntoView {
let summary = fail.summary();
let worth_retrying = fail.worth_retrying();
let status = fail.status;
let code = fail.code.clone();
let message = fail.message.clone();
view! {
<div class="failure" role="alert">
<h3>{summary}</h3>
{(!message.is_empty()).then(|| view! { <p class="detail">{message}</p> })}
<footer>
<span>
{if code == "restoring" {
// The summary says it all; no status line under it.
String::new()
} else if code.is_empty() {
format!("HTTP {status}")
} else {
format!("HTTP {status} · Gunnar: {code}")
}}
</span>
{(worth_retrying && retry.is_some()).then(|| {
let retry = retry.expect("checked just above");
view! {
<button class="btn quiet" on:click=move |_| retry.run(())>
"Try again"
</button>
}
})}
</footer>
</div>
}
}
/// How much of a list is on screen.
///
/// gunnar returns every row whatever is asked for, so the console knows the
/// total exactly and can say so instead of implying there is more to page to.
#[component]
pub fn Truncation(shown: usize, total: usize) -> impl IntoView {
view! {
<p class="truncation">
{format!("Showing {shown} of {total}. Gunnar does not paginate, so the rest was fetched but is not rendered.")}
</p>
}
}
/// ★ **One line, and a "?" for the rest** (owner, 2026-10-04: "long
/// hard-coded texts → one line + ?").
///
/// A paragraph under every panel is read once and skipped forever after, and
/// it pushes the one line somebody came for below the fold. So an explanation
/// is ONE line on screen; whatever else is worth saying sits behind the "?"
/// beside it — a native `<details>`, so it opens with a click or a key, needs
/// no script, and a screen reader announces it as the disclosure it is.
/// `more` empty draws the line alone.
///
/// `pages_say_one_line_and_hide_the_rest` (`crate::hint`) holds every page to
/// it: no long literal anywhere in a page except a `more=` here.
#[component]
pub fn Hint(#[prop(into)] line: String, #[prop(into, optional)] more: String) -> impl IntoView {
let more = more.trim().to_owned();
view! {
<div class="hint hint-line">
<span>{line}</span>
{(!more.is_empty()).then(|| view! {
<details class="why">
<summary title="Why?" aria-label="Why?">"?"</summary>
<p>{more}</p>
</details>
})}
</div>
}
}
/// ★ **An "i" beside a switch, and its short box** (UI-ENC, owner 2026-10-26): `markup` is the
/// button and its `role="tooltip"` box, written once in an `.html` file beside its page (the
/// words live there and nowhere else). Hover, keyboard focus and a tap show it (the
/// stylesheet's `.infotip`); Esc takes the focus off the button, which closes it.
#[component]
pub fn InfoTip(markup: &'static str) -> impl IntoView {
let close = move |ev: leptos::ev::KeyboardEvent| {
if ev.key() == "Escape" {
use wasm_bindgen::JsCast as _;
if let Some(active) = document().active_element() {
if let Ok(el) = active.dyn_into::<web_sys::HtmlElement>() {
let _ = el.blur();
}
}
}
};
view! { <span class="infotip" inner_html=markup on:keydown=close></span> }
}
/// A value the server never fills in.
#[component]
pub fn Unset(#[prop(into)] why: String) -> impl IntoView {
view! { <span class="unset" title=why>"—"</span> }
}