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/app.rs

//! The console's shell: navigation, session, and the routes.

use leptos::prelude::*;
use leptos_router::components::{Route, Router, Routes, A};
use leptos_router::path;
use serde::Deserialize;

use crate::auth::AuthMode;
use crate::pages;
use crate::theme::Theme;
use crate::ui::{Badge, Tone};

/// Who is logged in, shared by every page through context.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Status {
    pub authenticated: bool,
    pub user: String,
    pub role: Option<String>,
    pub passkey_enabled: bool,
    /// True when nothing guards the console at all.
    pub open: bool,
    /// `GUNNAR_UI_GIT_URL` on the server: the base git clones from
    /// (`ssh://host:port`), `None` when the operator set none.
    #[serde(default)]
    pub git_url: Option<String>,
    /// **The administrator's switches this console's build carries** (`public-site`,
    /// `mcp`), as `/auth/status` tells an administrator (GUI-PUBSW); empty for anybody
    /// else, and from a console that carries none or predates the field.
    #[serde(default)]
    pub switches: Vec<String>,
    /// **The ONE sign-up page's two doors**, as `/auth/status` of a build that
    /// sells names them (`/signup?plan=10gb`: a signed-in person's account
    /// grows there; `/signup?plan=private`: a private Gunnar). Absent where
    /// nothing is sold; the console holds no address of its own.
    #[serde(default)]
    pub buy_url: Option<String>,
    #[serde(default)]
    pub private_url: Option<String>,
    /// **Where a visitor signs up**, where this console signs people up (`/signup`);
    /// absent elsewhere. "Sign in to …" offers it beside the login (GUN-GUEST).
    #[serde(default)]
    pub signup_url: Option<String>,
    /// **The account this person acts in, and as what** — gunnar's answer
    /// (`Access.WhoAmI`, 2026-10-07: a customer is an ACCOUNT that pays; its
    /// members use its space and never buy). `account_role` is `admin`,
    /// `individual` or `employee`; absent with no account.
    #[serde(default)]
    pub account: Option<String>,
    #[serde(default)]
    pub account_role: Option<String>,
    /// The account admin's name: whom a member asks for more room.
    #[serde(default)]
    pub account_admin: Option<String>,
    /// The server's one sentence for a member, where the shop would be.
    #[serde(default)]
    pub account_line: Option<String>,
    /// The server's word on whether this session reaches the shop at all (read where the
    /// way to more room is drawn: `mirror::room_way`).
    #[serde(default)]
    pub may_buy: Option<bool>,
    /// **Whether THIS console's shop is its own** (one shop, 2026-10-07):
    /// `false` on a SOLD (provided) Gunnar, whose room and instances are its
    /// provider's to give; absent from an older server, which sells.
    #[serde(default)]
    pub shop_here: Option<bool>,
    /// On a provided Gunnar, the server's one line where the shop would be.
    #[serde(default)]
    pub shop_line: Option<String>,
    /// **The site this Gunnar was bought at** (`https://<apex>`; the server's
    /// `GUNNAR_UI_PROVIDER`), on a sold Gunnar; absent otherwise.
    #[serde(default)]
    pub provider: Option<String>,
    /// **This Gunnar's name at that site** (`GUNNAR_UI_INSTANCE`).
    #[serde(default)]
    pub instance: Option<String>,
    /// **gunnar is restarting** (GUN-SESSION-RESTART): the browser holds a session the
    /// server could not ask gunnar about yet. Not signed out: the console waits and asks
    /// again ([`crate::auth::ask_status_again_in_ms`]). Absent from an older server.
    #[serde(default)]
    pub restarting: bool,
}

impl Status {
    /// True when this session may change things.
    pub fn may_write(&self) -> bool {
        self.open || matches!(self.role.as_deref(), Some("operator" | "admin"))
    }

    /// **A member of an account** (gunnar's word): uses the account's space,
    /// is shown no shop, and reads the account's room.
    pub fn is_member(&self) -> bool {
        crate::nav::is_member_role(self.account_role.as_deref())
    }

    /// **The account's Audit log is offered** — its admin, appointed or not, and the
    /// operator; never a plain member (gunnar admits the account's admins alone).
    pub fn shows_audit_log(&self) -> bool {
        crate::nav::shows_audit_log(self.account_role.as_deref())
    }

    /// **True when THIS console's shop is its own** (the server's
    /// `shop_here`). A provided Gunnar draws no shop: the one line where it is.
    pub fn sells_here(&self) -> bool {
        crate::offers::shop_here(self.shop_here)
    }

    /// Where "Grow this instance" leads on a sold Gunnar: its shop's sign-up
    /// page, growing it ([`crate::offers::grow_this_instance`]).
    pub fn grow_this_instance(&self) -> Option<String> {
        crate::offers::grow_this_instance(self.sells_here(), self.provider.as_deref(), self.instance.as_deref())
    }

    /// True when this session may manage identity and passkeys.
    pub fn is_admin(&self) -> bool {
        self.open || self.role.as_deref() == Some("admin")
    }

    pub fn mode(&self) -> AuthMode {
        if self.passkey_enabled {
            AuthMode::Passkey
        } else {
            AuthMode::Open
        }
    }
}

/// The session, once `/auth/status` has answered.
#[derive(Clone, Copy)]
pub struct SessionContext(pub RwSignal<Option<Status>>);

/// **A bump every time something that might change the queue has changed.**
///
/// The Åtgärder bar polls slowly on purpose — nothing in that queue moves in
/// less than days — but a person who has just FIXED one of its items should not
/// sit under a banner about it for another five minutes. So the bar re-reads on
/// this signal as well as on its timer, and anything that resolves an item
/// bumps it. Measured 2026-09-18: the owner vouched for the console's key, the
/// panel vanished, and the bar went on saying the opposite.
#[derive(Clone, Copy)]
pub struct ActionsBeat(pub RwSignal<u32>);

/// Tell the Åtgärder bar to look again. Silent when there is no bar — a page
/// rendered outside the shell in a test has nothing to nudge.
pub fn nudge_actions() {
    if let Some(ActionsBeat(beat)) = use_context::<ActionsBeat>() {
        beat.update(|n| *n += 1);
    }
}

/// Read the session from context.
///
/// `None` until the first answer arrives. Pages must not guess in the meantime:
/// rendering write affordances for a viewer, or hiding them from an admin, are
/// both worse than waiting one round trip.
pub fn session() -> RwSignal<Option<Status>> {
    use_context::<SessionContext>()
        .expect("the shell provides the session")
        .0
}

/// **The one line the shell carries when this account has something to do.**
///
/// Polled rather than pushed, and slowly: nothing in this queue is urgent to
/// the minute — a subscription period and a disk both move over days — and a
/// console that asked every few seconds would spend a tenant's appliance budget
/// on a question whose answer almost never changes. It refreshes on navigation
/// (the component is rebuilt) and otherwise every five minutes.
///
/// A failed read draws NOTHING. This is the shell: an error here would appear
/// on every page at once, about a queue, which is the least important thing on
/// any of them.
#[component]
fn ActionBar() -> impl IntoView {
    use crate::actions::{bar, Actions, Bar};

    // The beat is the ONE thing that makes this re-read: the timer below bumps
    // it every five minutes, and [`nudge_actions`] bumps it the moment somebody
    // resolves an item. One path, so a fix and a tick cannot behave differently.
    let beat = use_context::<ActionsBeat>().map(|b| b.0).unwrap_or_else(|| RwSignal::new(0));
    let answer = LocalResource::new(move || {
        beat.track();
        async { crate::api::get::<Actions>("/api/account/actions").await }
    });
    Effect::new(move |_| {
        leptos::task::spawn_local(async move {
            loop {
                gloo_timers::future::TimeoutFuture::new(5 * 60 * 1000).await;
                beat.update(|n| *n += 1);
            }
        });
    });

    move || match answer.get().and_then(|a| a.ok()).as_ref().map(bar) {
        Some(Bar::Show { line, urgent, href }) => view! {
            <a
                class=if urgent { "actions-bar urgent" } else { "actions-bar" }
                href=href
                role="status"
            >
                <span class="actions-dot" aria-hidden="true"></span>
                <span class="actions-line" title=line.clone()>{line.clone()}</span>
            </a>
        }
        .into_any(),
        _ => ().into_any(),
    }
}

/// The console.
#[component]
pub fn App() -> impl IntoView {
    let status = RwSignal::new(None::<Status>);
    provide_context(SessionContext(status));
    provide_context(ActionsBeat(RwSignal::new(0u32)));

    Effect::new(move |_| {
        leptos::task::spawn_local(async move {
            // GUN-SESSION-RESTART: while gunnar restarts the server says `restarting`; the
            // console keeps asking until it knows who this session is, instead of drawing
            // a signed-out console.
            loop {
                let Ok(fresh) = crate::api::get::<Status>("/auth/status").await else {
                    break;
                };
                let again = crate::auth::ask_status_again_in_ms(fresh.restarting);
                status.set(Some(fresh));
                match again {
                    Some(ms) => gloo_timers::future::TimeoutFuture::new(ms).await,
                    None => break,
                }
            }
        });
    });

    // ★ **Whether this browser has a session at all**, which is a coarser
    // question than `may_write()` or `is_admin()` and the only one the shell
    // asks. Anonymous browsing of public repositories is a real, persistent
    // state now — not a half-loaded console — and everything outside `/repos`
    // and `/{ns}/{name}/**` is refused in it, documents included.
    //
    // `open` counts as signed in: with nothing guarding the console every route
    // answers, and hiding half the navigation there would be a lie about a
    // console that has no lock on it.
    //
    // `is_some_and` — so an unanswered `/auth/status` counts as NOT signed in —
    // follows the rule already written on [`session`]: the console waits one
    // round trip rather than guessing. Guessing the other way would flash the
    // operator's navigation at a visitor who may not have it, and every entry
    // in that flash is a link that ends in a 401.
    let signed_in = Memo::new(move |_| status.get().is_some_and(|s| s.authenticated || s.open));
    // The one navigation entry that is an admin's. Same rule as `signed_in`:
    // an unanswered `/auth/status` counts as NOT, so the console waits a round
    // trip rather than flashing a link that would end in a 403.
    let is_admin = Memo::new(move |_| status.get().is_some_and(|s| s.is_admin()));
    // The Administration group's own floor. Wider than `is_admin` on purpose:
    // `/admin` carries the twin's heartbeat, which is an operator's read, and
    // an admin-only group would have hidden the one page that shows it from the
    // role that is allowed to see it.
    let may_write = Memo::new(move |_| status.get().is_some_and(|s| s.may_write()));

    // The badger leads somewhere the reader may actually go. `/` is the
    // Overview, which is refused without a session, so for a visitor the
    // wordmark points at the catalogue instead — clicking the logo must never
    // be the thing that ejects someone from anonymous browsing.
    let reader = move || crate::nav::Reader {
        signed_in: signed_in.get(),
        may_write: may_write.get(),
        is_admin: is_admin.get(),
        // gunnar's word (`account_role: employee`): a member's You ▾ has no Clans.
        member: status.get().is_some_and(|s| s.is_member()),
    };
    // "You ▾" carries the reader's own name, so who is signed in is still on
    // the bar (it was the Identity badge's job).
    let you_label = move || {
        status.get().map(|s| if s.user.is_empty() { "You".to_owned() } else { s.user.clone() }).unwrap_or_else(|| "You".to_owned())
    };

    view! {
        <Router>
            <TitleKeeper/>
            <div class="app">

                <div class="workspace">
                    // ★ **The top bar IS the navigation** (owner's final
                    // navigation, 2026-10-02): `[gunnar] Repositories
                    // (search…)  You ▾  Administration ▾`, from
                    // `crate::nav::menu`, where a test states it. The sidebar is
                    // gone. Nothing here is a security boundary: the server
                    // refuses every route whatever is drawn.
                    <header class="topbar">
                        <a class="skip" href="#main">"Skip to content"</a>
                        <a class="brand-top" href="/repos" aria-label="Gunnar — Repositories">
                            <span class="brand-mark brand-mark-small" aria-hidden="true"></span>
                            <span>"Gunnar"</span>
                        </a>
                        {move || crate::nav::menu(reader()).main.into_iter().map(|l| view! {
                            <A href=l.href attr:class="top-link">{l.label}</A>
                        }).collect_view()}
                        <TopSearch/>
                        // ★ **The health badge is an operator's instrument.**
                        // `GET /api/health` is not one of the routes the server
                        // answers for an anonymous visitor, so for someone
                        // browsing a public repository without a session it
                        // returns 401 — and `ServingBadge` renders any error as
                        // "gunnar is not responding". That is a false alarm
                        // about the SERVER caused by the READER not being
                        // logged in, and it is the first thing a visitor would
                        // see on the page.
                        //
                        // The gate is a `<Show>` around the component rather
                        // than a branch inside it, because the badge builds its
                        // `LocalResource` as it is created: a branch in the
                        // view would hide the badge and still send the request.
                        // The condition is `signed_in`, the same memo the
                        // sidebar uses: an open console still gets the badge,
                        // because there the call succeeds and an operator wants
                        // gunnar's version on screen.
                        //
                        // Since 2026-10-04 (thin layer) `/api/health` is asked
                        // AS THE READER, and gunnar answers `Admin.Health` to
                        // a server-wide Read grant only — a customer's own key
                        // is refused. So the badge is the instrument of the
                        // sessions gunnar answers it for, and a customer is not
                        // shown "gunnar is not responding" about a read that
                        // was never theirs.
                        <Show when=move || may_write.get()>
                            <ServingBadge/>
                        </Show>
                        // ★ **Åtgärder.** One line, between the health badge
                        // and the spacer, on every page — because the things
                        // it carries ("you cannot push yet", "your period ends
                        // this week") are true wherever the reader happens to
                        // be, and a message that only appears on the page
                        // somebody never opens is not a message.
                        //
                        // Gated on `signed_in` for the same reason the badge
                        // is: `/api/account/actions` takes its subject from the
                        // session, so for a visitor it is a 401 and the bar
                        // would render a refusal about an account they do not
                        // have. It draws NOTHING when there is nothing to say,
                        // which is the ordinary state and what keeps it worth
                        // reading on the day it appears.
                        <Show when=move || signed_in.get()>
                            <ActionBar/>
                        </Show>
                        <span class="spacer"></span>
                        <ThemeToggle/>
                        {move || {
                            let m = crate::nav::menu(reader());
                            if m.you.is_empty() {
                                view! { <Identity/> }.into_any()
                            } else {
                                view! { <MenuDrop label=you_label() links=m.you logout=true/> }.into_any()
                            }
                        }}
                        {move || {
                            let m = crate::nav::menu(reader());
                            (!m.admin.is_empty()).then(|| view! {
                                <MenuDrop label="Administration".to_owned() links=m.admin admin=true/>
                            })
                        }}
                    </header>

                    <main class="page" id="main">
                        <Routes fallback=unmatched>
                            // `/` leads a person to Repositories and an
                            // administrator to the Overview (`nav::moved`).
                            <Route path=path!("/") view=|| view! { <Placed/> }/>
                            <Route path=path!("/overview") view=|| view! { <Placed/> }/>
                            <Route path=path!("/admin/overview") view=|| view! { <Placed><pages::overview::Overview/></Placed> }/>
                            <Route path=path!("/repos") view=pages::repos::Repos/>
                            // The old `/repo/…` addresses (GUN-GUEST): the server answers
                            // them 301, and a link the console still holds takes the same
                            // road here (`crate::addr::from_old`).
                            <Route path=path!("/repo") view=RepoMoved/>
                            <Route path=path!("/repo/*rest") view=RepoMoved/>
                            <Route path=path!("/access") view=|| view! { <Placed><pages::access::Access/></Placed> }/>
                            // `/storage` is gone: a person's storage is under
                            // Account, the machine's under Administration, and
                            // the old address leads to the right one.
                            <Route path=path!("/storage") view=|| view! { <Placed/> }/>
                            <Route path=path!("/account/storage") view=pages::my_storage::StorageGoesOn/>
                            <Route path=path!("/admin/storage") view=|| view! { <Placed><pages::admin_storage::AdminStorage/></Placed> }/>
                            // `/machine` is gone (owner, 2026-10-04): the
                            // Overview's Machine card draws the estate and its
                            // few lines, Administration → Storage every disk
                            // detail. The old address leads there (`nav::moved`).
                            <Route path=path!("/machine") view=|| view! { <Placed/> }/>
                            // Per-namespace meters. `/usage` picks; `/usage/:namespace`
                            // is the page itself, and it is a separate address so a
                            // namespace's own numbers can be linked to.
                            // An administrator's; for anybody else they lead to
                            // their own storage (`nav::moved`), so a buy link or
                            // a bookmark never breaks.
                            <Route path=path!("/usage") view=|| view! { <Placed><pages::usage::UsageIndex/></Placed> }/>
                            <Route path=path!("/usage/:namespace") view=|| view! { <Placed><pages::usage::NamespaceUsage/></Placed> }/>
                            // Mirrors: the panel and the picker. Two
                            // top-level routes and not one with a query
                            // parameter — that was tried and MEASURED, on the
                            // theory that `leptos_router` monomorphises one
                            // `EitherOf<N>` per route count (the note in
                            // `.github/wasm-baseline`). It made the bundle
                            // 3 268 bytes LARGER, so the address that reads
                            // better is the one that ships.
                            <Route path=path!("/mirrors") view=pages::mirrors::Mirrors/>
                            <Route path=path!("/cli") view=pages::cli::Cli/>
                            <Route path=path!("/mirrors/import") view=pages::mirrors::ImportPicker/>
                            // The whole server's events are the Overview's
                            // (an admin's); a person's are their account's.
                            <Route path=path!("/events") view=|| view! { <Placed/> }/>
                            <Route path=path!("/account") view=pages::account::Account/>
                            <Route path=path!("/account/activity") view=pages::activity::AccountActivity/>
                            // You → Clans (2026-10-05): the person's own clans.
                            <Route path=path!("/account/clans") view=pages::clans::ClansPage/>
                            // `/paid/{reference}` (the vendor's return) is NOT a
                            // route here: it is `pages::subscription::vendor_return`,
                            // reached through `unmatched()` and gated `metered`,
                            // so an open build has no such address.
                            // Administration. The panels it draws came from
                            // `/account`, `/storage` and the Overview, each
                            // with its own gate travelling with it; the page
                            // adds one `may_write()` floor above them and the
                            // banner that says which half of the console this
                            // is. See `pages::admin`.
                            <Route path=path!("/admin") view=pages::admin::Admin/>
                            // Company SSO (2026-10-05): the ACCOUNT admin's — gunnar admits
                            // them alone; not behind `<Placed>`, which is the box admin's.
                            <Route path=path!("/admin/identity-provider") view=pages::identity_provider::IdentityProvider/>
                            // The owner's enterprise list (2026-10-05): the accounts'
                            // TOTALS are the box admin's; the Audit log is the
                            // ACCOUNT admin's — gunnar admits them alone, so it is
                            // not behind `<Placed>`, which is the box admin's.
                            <Route path=path!("/admin/accounts") view=|| view! { <Placed><pages::account_totals::AccountTotals/></Placed> }/>
                            <Route path=path!("/admin/audit") view=pages::audit_log::AuditLog/>
                            // ★ **A repository is `/{ns}/{name}`** (GUN-GUEST, owner
                            // 2026-10-30): the one address scheme, the same for a
                            // visitor, a person and a crawler (`crate::addr`). After
                            // every named route above, so `/admin/storage` stays the
                            // admin's page; `/{ns}` is last of all.
                            // Every repository page sits behind `RepoGate`: a
                            // namespace or repository the caller cannot see is
                            // drawn as "This page does not exist" and NO panes
                            // (`crate::gate`; the server's `api::absent`).
                            <Route path=path!("/:namespace/:name") view=RepoHome/>
                            <Route path=path!("/:namespace/:name/tree") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/tree/*path") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/blob/*path") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/commits") view=|| view! { <pages::RepoGate><pages::graph::CommitGraph/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/pulls") view=|| view! { <pages::RepoGate><pages::pulls::PullsList/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/pulls/:number") view=|| view! { <pages::RepoGate><pages::pulls::PullDetail/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/issues") view=|| view! { <pages::RepoGate><pages::issues::IssuesList/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/issues/:number") view=|| view! { <pages::RepoGate><pages::issues::IssueDetail/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/diff") view=|| view! { <pages::RepoGate><pages::diff::DiffView/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/blame") view=|| view! { <pages::RepoGate><pages::blame::BlameView/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/search") view=|| view! { <pages::RepoGate><pages::search::SearchView/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/chain") view=|| view! { <pages::RepoGate><pages::chain::ChainView/></pages::RepoGate> }/>
                            // The repository's own events (its Activity tab) and
                            // its settings (visibility, maintenance, mirror).
                            <Route path=path!("/:namespace/:name/activity") view=|| view! { <pages::RepoGate><pages::activity::RepoActivity/></pages::RepoGate> }/>
                            <Route path=path!("/:namespace/:name/settings") view=|| view! { <pages::RepoGate><pages::repo::RepoSettings/></pages::RepoGate> }/>
                            // **`/<ns>`** (owner, 2026-10-03): a namespace as
                            // the reader may see it — a visitor sees a private
                            // namespace's public repositories only, and one with
                            // none is "This page does not exist". Last, so every
                            // named route above it wins.
                            <Route path=path!("/:namespace") view=pages::namespace::NamespacePage/>
                        </Routes>
                    </main>
                </div>
            </div>
        </Router>
    }
}

/// Whether gunnar is answering.
#[component]
fn ServingBadge() -> impl IntoView {
    let health = LocalResource::new(|| async {
        crate::api::get::<pages::overview::Health>("/api/health").await
    });

    view! {
        {move || match health.get() {
            None => view! { <Badge tone=Tone::Plain label="checking …"/> }.into_any(),
            Some(Err(_)) => view! { <Badge tone=Tone::Bad label="Gunnar is not responding"/> }.into_any(),
            Some(Ok(health)) => {
                let version = health.version.clone();
                if !health.serving {
                    view! { <Badge tone=Tone::Bad label="Gunnar is not accepting requests"/> }.into_any()
                } else if !health.grpc_authentication_enabled {
                    // gunnar reports this on the wire precisely so an operator
                    // can see the hole. Showing it as "fine" would waste that.
                    view! {
                        <Badge tone=Tone::Warn label="control plane is unauthenticated"/>
                    }.into_any()
                } else {
                    view! { <Badge tone=Tone::Pass label=format!("Gunnar {version}")/> }.into_any()
                }
            }
        }}
    }
}

/// This browser's stored choice of theme.
///
/// Read in two places and they must agree: here, and the four lines in
/// `index.html` that apply it before the first paint (`crate::theme` holds
/// the names and a test holds both to them). Changing one without the other is
/// a console that forgets the choice on every reload.
///
/// Every call goes through a `try`-shaped path: `local_storage()` ANSWERS AN
/// ERROR rather than `None` in a private window, which is exactly where a
/// console that unwrapped would stop at a blank page over a colour.
fn stored_theme() -> Theme {
    let word = web_sys::window()
        .and_then(|w| w.local_storage().ok().flatten())
        .and_then(|store| store.get_item(crate::theme::KEY).ok().flatten());
    Theme::from_stored(word.as_deref())
}

/// Paint the choice and remember it.
///
/// The attribute is what the stylesheet reads (`:root[data-theme="dark"]`,
/// `:root[data-theme="surprise"]`); bare `:root` is the light palette, so
/// light is the ABSENCE of the attribute and not a value — one less state that
/// can disagree with `index.html`'s pre-paint script.
fn apply_theme(theme: Theme) {
    let Some(window) = web_sys::window() else {
        return;
    };
    if let Some(root) = window.document().and_then(|d| d.document_element()) {
        match theme.attr() {
            Some(value) => {
                let _ = root.set_attribute("data-theme", value);
            }
            None => {
                let _ = root.remove_attribute("data-theme");
            }
        }
    }
    // Storage may be absent, refused, or full. None of those is a reason to
    // fail the click: the theme is already on screen by this line.
    if let Ok(Some(store)) = window.local_storage() {
        let _ = store.set_item(crate::theme::KEY, theme.name());
    }
}

/// ★ **The theme switch — a choice, never an inference.**
///
/// The console is white by default. The dark palette it shipped with is kept
/// whole and selectable, because an operator with a terminal beside the
/// browser has a real reason to want it, and since 2026-10-05 a third, the
/// surprise theme (dark Nordic, purple and cyan), comes after it: one press
/// walks ☀ light → ☾ dark → ✦ surprise → ☀ light (`crate::theme`).
/// `prefers-color-scheme` is deliberately not consulted — see the note at the
/// top of `styles/console.css`.
///
/// The label says what the click DOES, not what the console currently is: a
/// button that reads "Dark" in a light console is a button whose label is an
/// instruction, and that is the only reading that survives being seen for half
/// a second on the way past.
#[component]
fn ThemeToggle() -> impl IntoView {
    let theme = RwSignal::new(stored_theme());

    view! {
        <button
            class="btn quiet theme-toggle"
            type="button"
            title=move || theme.get().title()
            on:click=move |_| {
                let next = theme.get_untracked().next();
                theme.set(next);
                apply_theme(next);
            }
        >
            {move || theme.get().label()}
        </button>
    }
}

/// Who is logged in, and the way out.
#[component]
fn Identity() -> impl IntoView {
    let status = crate::app::session();

    view! {
        {move || match status.get() {
            None => view! { <span class="badge plain">"…"</span> }.into_any(),
            Some(status) if status.open => view! {
                <Badge tone=Tone::Warn label="unlocked console"/>
            }.into_any(),
            // GUN-SESSION-RESTART: gunnar is restarting; the session is kept, no login offered.
            Some(status) if status.restarting => view! {
                <Badge tone=Tone::Warn label="Gunnar is starting…"/>
            }.into_any(),
            Some(status) if status.authenticated => {
                let who = format!("{} · {}", status.user, status.role.clone().unwrap_or_default());
                view! {
                    <span class="badge plain">{who}</span>
                    <button class="btn quiet" on:click=move |_| leptos::task::spawn_local(log_out())>"Log out"</button>
                }.into_any()
            }
            // Signed out — which is now a normal way to be here, not an error
            // state, because a public repository can be browsed with no
            // session. This link is the ONLY way back in the console offers
            // such a visitor: the sidebar leads nowhere they may go and no page
            // they can reach paints a failure box with a login in it any more,
            // now that the gated panels are not mounted for them at all.
            //
            // `primary` and not `quiet`: the quiet variant is transparent with
            // dimmed ink, and at the far right of a 52px top bar it reads as a
            // caption rather than the way in. The badge beside it says WHY the
            // console looks emptier than it should — without it, a visitor has
            // no way to tell "signed out" from "this deployment has nothing".
            //
            // `rel="external"`, and not decoration: `/login` is a page the
            // SERVER renders, and the console has no route for it. The router
            // claims every same-origin anchor click for itself unless the
            // anchor says otherwise, and a claimed click on a route it does
            // not have moves the URL bar and nothing else — measured on
            // gunnar.rs 2026-09-10 from a public repository's graph: "Log
            // in" did nothing. `rel="external"` is the router's own opt-out
            // and gives the click back to the browser, which is what every
            // other road to `/login` in this crate already takes by setting
            // `location.href`. The `next` carries the page the visitor was
            // on, so logging in returns them to it.
            Some(_) => {
                let here = web_sys::window()
                    .and_then(|w| w.location().pathname().ok())
                    .unwrap_or_else(|| "/".to_owned());
                view! {
                    <Badge tone=Tone::Plain label="not signed in"/>
                    <a class="btn primary" rel="external" href=crate::auth::login_url(&here)>"Log in"</a>
                }.into_any()
            }
        }}
    }
}

/// **Log out**: to the login page once gunnar ended the session; when it did not
/// (RA5-11), its words, and the person stays signed in (the cookie is kept).
async fn log_out() {
    let Some(window) = web_sys::window() else { return };
    match crate::api::post_empty::<serde_json::Value>("/auth/logout").await {
        Ok(_) => {
            let _ = window.location().set_href("/login");
        }
        Err(fail) => {
            let _ = window.alert_with_message(&fail.message);
        }
    }
}

/// **A top-bar menu**: a `<details>` that drops its links, closes when one is
/// followed, and — for You ▾ — ends with Log out. `<details>` and not a script
/// so it works on a phone, by keyboard and with no JavaScript of its own.
#[component]
fn MenuDrop(
    label: String,
    links: Vec<crate::nav::Link>,
    #[prop(optional)] logout: bool,
    #[prop(optional)] admin: bool,
) -> impl IntoView {
    let close = |ev: leptos::ev::MouseEvent| {
        use wasm_bindgen::JsCast as _;
        if let Some(d) = ev
            .target()
            .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
            .and_then(|e| e.closest("details").ok().flatten())
        {
            let _ = d.remove_attribute("open");
        }
    };
    view! {
        <details class=if admin { "menu admin-menu" } else { "menu" }>
            <summary>{label}" \u{25be}"</summary>
            <div class="menu-items" on:click=close>
                {links.into_iter().map(|l| view! { <A href=l.href attr:class="menu-link">{l.label}</A> }).collect_view()}
                {logout.then(|| view! {
                    <button class="menu-link menu-logout" on:click=move |_| leptos::task::spawn_local(log_out())>"Log out"</button>
                })}
            </div>
        </details>
    }
}

/// **The search box on the top bar**: a repository filter, answered by the
/// Repositories page (`/repos?q=`).
#[component]
fn TopSearch() -> impl IntoView {
    let navigate = leptos_router::hooks::use_navigate();
    let typed = RwSignal::new(String::new());
    let go = move |ev: leptos::ev::SubmitEvent| {
        ev.prevent_default();
        let q = typed.get_untracked();
        let to = if q.trim().is_empty() { "/repos".to_owned() } else { format!("/repos?q={}", crate::auth::percent_encode(q.trim())) };
        navigate(&to, Default::default());
    };
    view! {
        <form class="top-search" role="search" on:submit=go>
            <label class="vh" for="top-search">"Search repositories"</label>
            <input
                id="top-search"
                type="search"
                placeholder="Search…"
                prop:value=move || typed.get()
                on:input=move |ev| typed.set(event_target_value(&ev))
            />
        </form>
    }
}

/// **The document's title follows the address** (GUN-FIELD-TEST, 2026-11-01): the page,
/// the repository and the site (`crate::title`). The site's name is the one the server's
/// first document carried.
#[component]
fn TitleKeeper() -> impl IntoView {
    let location = leptos_router::hooks::use_location();
    let site = web_sys::window()
        .and_then(|w| w.document())
        .map(|d| crate::title::site_of(&d.title()))
        .unwrap_or_else(|| "Gunnar".to_owned());
    Effect::new(move |_| {
        let title = crate::title::of(&location.pathname.get(), &site);
        if let Some(d) = web_sys::window().and_then(|w| w.document()) {
            d.set_title(&title);
        }
    });
}

/// **A page that may have moved** (`crate::nav::moved`): once the session is
/// known, an address that leads elsewhere for this reader is replaced by where
/// it leads; otherwise the page is drawn. Waits for `/auth/status` rather than
/// guessing, so nobody is bounced on a half-loaded console.
#[component]
fn Placed(#[prop(optional)] children: Option<ChildrenFn>) -> impl IntoView {
    let status = session();
    let location = leptos_router::hooks::use_location();
    let navigate = leptos_router::hooks::use_navigate();
    move || {
        let Some(s) = status.get() else {
            return view! { <crate::ui::Loading/> }.into_any();
        };
        match crate::nav::moved(&location.pathname.get(), s.is_admin()) {
            Some(to) => {
                navigate(to, leptos_router::NavigateOptions { replace: true, ..Default::default() });
                ().into_any()
            }
            None => match children.as_ref() {
                Some(children) => children().into_any(),
                None => ().into_any(),
            },
        }
    }
}

/// **An old `/repo/…` address** (GUN-GUEST): replaced by its new form at once
/// (`crate::addr::from_old`), so a link the console still holds lands where the
/// server's 301 would have sent it.
#[component]
fn RepoMoved() -> impl IntoView {
    let location = leptos_router::hooks::use_location();
    let navigate = leptos_router::hooks::use_navigate();
    let path = location.pathname.get_untracked();
    let query = location.search.get_untracked();
    let to = crate::addr::from_old(&path, &query).unwrap_or_else(|| "/repos".to_owned());
    navigate(&to, leptos_router::NavigateOptions { replace: true, ..Default::default() });
}

/// **`/{ns}/{name}`**, a repository's home. In a build that sells, the one outside
/// address with the same shape, the pay link `/billing/{tenant}`, goes to its page first.
#[component]
fn RepoHome() -> impl IntoView {
    #[cfg(feature = "metered")]
    if let Some(page) = crate::pages::subscription::vendor_return() {
        return page;
    }
    view! { <pages::RepoGate><pages::repo::RepoDetail/></pages::RepoGate> }.into_any()
}

/// **An address no route names.** One exception, and only in a build that
/// sells: the pay link every refusal ends in (`/billing/{tenant}`), which lives
/// with the rest of that flow in `pages::subscription` (the money scanner's
/// `METERED_ONLY`). The vendor's ways back are the server's ONE order page.
fn unmatched() -> AnyView {
    #[cfg(feature = "metered")]
    if let Some(page) = crate::pages::subscription::vendor_return() {
        return page;
    }
    view! {
        <div class="empty">
            <h3>"Page not found"</h3>
            <p>"This address leads nowhere in this console."</p>
        </div>
    }
    .into_any()
}