Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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> }
}