git-ents.gitmain
⌘K
foforge
commit 36a6092
roots: regroup the nav into four tabs with a registry-driven meta rail

Nine equal tabs hardcoded every meta-ref namespace as top-level chrome; the abstractions say those are typed trees behind meta-refs, not platform features. The strip shrinks to overview/files/comments/meta; the five meta namespaces share one META_SECTIONS registry that drives both a GET /meta index card and a rail beside every meta page, so a new entity type joins the UI as one registry row plus its route. Account leaves the tab strip for a header identity chip read off the injected SigningIdentity, and the remote Google Fonts load gives way to system font stacks — a local, offline-first forge should not phone fonts.gstatic.com.

roots: collapse members/effects/toolchains/redactions/inbox behind one meta tab and rail roots: add a GET /meta index generated from the META_SECTIONS registry roots: move account from the tab strip to a header identity chip roots: replace the remote Google Fonts load with system font stacks Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/assets.rs @@ -2,9 +2,9 @@ //! self-contained -- no runtime fetch, no separate asset bundle to ship //! alongside `git-ents`. `ents.css` is the hand-rolled pre-redo stylesheet //! (`pre-redo:crates/git-ents-server/src/web/style.css`), ported rather -//! than vendored. [`FONTS_HREF`] is this crate's one exception: the -//! pre-redo brand type stack is only available from Google Fonts, so it is -//! loaded at request time rather than embedded. +//! than vendored -- including its type stack, which this crate's own +//! system-font fallback carries rather than the pre-redo Google Fonts load +//! (see `ents.css`'s own header comment). //! //! The icon functions below are vendored Octicons (`.gitvendors`, MIT; see //! `assets/icons/LICENSE`), re-homed here from @@ -21,11 +21,6 @@ pub(crate) const OVERRIDES: &str = include_str!("assets/ents.css"); -/// Google Fonts stylesheet URL for the pre-redo brand type stack (DM Sans, -/// IBM Plex Mono, Lora) -- mirrors -/// `pre-redo:crates/git-ents-server/src/web/assets.rs`'s `FONTS` const. -pub(crate) const FONTS_HREF: &str = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Lora:wght@500;600;700&display=swap"; - /// Adapt a vendored Octicon to this UI: tag it with the `.icon` class the /// stylesheet targets and mark it decorative for assistive tech. Every /// vendored file opens with a bare `<svg …>` element, so a single prefix
crates/cli/ents-web/src/router.rs @@ -50,6 +50,7 @@ .route("/effects/{name}", get(pages::effects::show::<O>)) .route("/files", get(pages::files::root::<O>)) .route("/files/{*path}", get(pages::files::show::<O>)) + .route("/meta", get(pages::meta::show::<O>)) .route("/redactions", get(pages::redactions::list::<O>)) .route("/redactions/{id}", get(pages::redactions::show::<O>)) .route("/toolchains", get(pages::toolchains::list::<O>))
crates/cli/ents-web/tests/router.rs @@ -640,6 +640,52 @@ assert!(body.contains("1 &lt; 2")); } +/// The `meta` tab restructure (`crate::pages::mod`'s own doc): `GET /meta` +/// is reachable as the tab's index page, and `GET /members` -- one of the +/// five page families that group now shares -- renders with the +/// `META_SECTIONS` rail visible and the `meta` tab (not a per-family tab) +/// highlighted. +#[tokio::test] +async fn meta_index_and_a_meta_group_page_render_with_the_rail() { + let state = build_state(FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(state); + + let meta_response = router + .clone() + .oneshot(Request::get("/meta").body(Body::empty()).expect("request")) + .await + .expect("in-process call"); + assert_eq!(meta_response.status(), StatusCode::OK); + + let members_response = router + .oneshot( + Request::get("/members") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(members_response.status(), StatusCode::OK); + let body = members_response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!( + body.contains("class=\"meta-rail\""), + "a meta-group page renders the section rail" + ); + assert!( + body.contains("class=\"tab active\""), + "the meta tab itself highlights, not a per-family tab" + ); +} + /// `GET /files/<path>` renders a `.md` blob as Markdown and a `.adoc` blob /// as AsciiDoc -- both a real rendered heading, not the raw source markup. #[tokio::test]
crates/cli/ents-web/src/assets/ents.css @@ -8,12 +8,18 @@ * (`.checks-grid`, `.status-*`, `.terminal-*`), and every chrome class for a * page family this crate does not have (releases, issues, settings, * account-strip sign-in). The `--s-*` syntax tokens are dropped with their - * only consumers. + * only consumers. The pre-redo brand type stack (DM Sans, IBM Plex Mono, + * Lora, loaded from Google Fonts) is dropped in favor of system font + * stacks, so this sheet never depends on a network fetch. Added beyond the + * pre-redo sheet: `.meta-layout`/`.meta-rail` for the `meta` tab's + * page-family rail (`crate::pages::META_SECTIONS`) and `.id-chip` for the + * header's signing-identity link, both new to this crate's own four-tab + * restructure. */ :root { - --font-sans: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-serif: "Lora", Georgia, "Times New Roman", serif; - --font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace; + --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif; + --font-serif: ui-serif, Georgia, serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; --max-width: 78rem; --color-bg: #faf8f4; --color-surface: #fff; @@ -77,6 +83,8 @@ .nav-search input:focus { border-color: var(--color-accent); } .nav-search:has(input:disabled) { opacity: .55; } .nav-search:has(input:disabled) input { opacity: 1; } +.id-chip { font-family: var(--font-mono); font-size: .78rem; font-weight: 600; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .25rem .75rem; white-space: nowrap; text-decoration: none; transition: border-color .15s, color .15s; } +.id-chip:hover { color: var(--color-accent); border-color: var(--color-accent); text-decoration: none; } /* Repo-header band: the served repository's name breadcrumb and HEAD-branch * pill, between the site nav and the tab strip (the Gitea-style repo header, @@ -95,6 +103,20 @@ .tab.active { color: var(--color-text); font-weight: 600; } .tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--color-accent); } +/* The `meta` tab's page-family rail (`crate::pages::META_SECTIONS`), + * rendered beside every meta-namespace page's own content + * (`crate::pages::layout_meta`). */ +.meta-layout { display: grid; grid-template-columns: 13rem minmax(0, 1fr); gap: 2rem; align-items: start; } +.meta-rail { display: flex; flex-direction: column; gap: .1rem; position: sticky; top: 78px; } +.meta-rail a { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); text-decoration: none; padding: .4rem .6rem; border-radius: var(--radius-sm); } +.meta-rail a:hover { color: var(--color-text); background: var(--color-code-bg); } +.meta-rail a.active { color: var(--color-accent); font-weight: 600; background: var(--color-accent-subtle); } + +@media (max-width: 900px) { + .meta-layout { grid-template-columns: minmax(0, 1fr); } + .meta-rail { position: static; flex-direction: row; flex-wrap: wrap; } +} + .content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; } .page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; } .page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
crates/cli/ents-web/src/pages/account.rs @@ -53,6 +53,7 @@ Ok(super::layout( &super::RepoHeader::from_state(&state), + &super::identity_label(&state), super::Tab::Account, "account", html! {
crates/cli/ents-web/src/pages/comments.rs @@ -35,6 +35,7 @@ let rows = comment::list(state.refs.as_ref(), &*state.objects())?; Ok(super::layout( &super::RepoHeader::from_state(&state), + &super::identity_label(&state), super::Tab::Comments, "comments", html! { @@ -86,6 +87,7 @@ )?; Ok(super::layout( &super::RepoHeader::from_state(&state), + &super::identity_label(&state), super::Tab::Comments, &id, html! {
crates/cli/ents-web/src/pages/dashboard.rs @@ -48,7 +48,8 @@ Ok(super::layout( &super::RepoHeader::from_state(&state), - super::Tab::Dashboard, + &super::identity_label(&state), + super::Tab::Overview, "overview", html! { div.overview {
crates/cli/ents-web/src/pages/effects.rs @@ -27,9 +27,10 @@ O: Find + Write + Send + 'static, { let rows = read_all(&state)?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Effects, + &super::identity_label(&state), + "/effects", "effects", crate::render::list_table(&rows, "name", |id| format!("/effects/{id}")), )) @@ -57,9 +58,10 @@ Ok(_) => "parses".to_owned(), Err(error) => format!("does not parse: {error}"), }; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Effects, + &super::identity_label(&state), + "/effects", &name, html! { (crate::render::view(&effect))
crates/cli/ents-web/src/pages/files.rs @@ -79,6 +79,7 @@ Err(_) if path.is_empty() => { return Ok(super::layout( &super::RepoHeader::from_state(state), + &super::identity_label(state), super::Tab::Files, "files", html! { @@ -98,6 +99,7 @@ let entries = tree_entries(&head_tree)?; return Ok(super::layout( &super::RepoHeader::from_state(state), + &super::identity_label(state), super::Tab::Files, "files", html! { @@ -123,6 +125,7 @@ let entries = tree_entries(&subtree)?; Ok(super::layout( &super::RepoHeader::from_state(state), + &super::identity_label(state), super::Tab::Files, path, html! { @@ -139,6 +142,7 @@ let name = path.rsplit('/').next().unwrap_or(path); Ok(super::layout( &super::RepoHeader::from_state(state), + &super::identity_label(state), super::Tab::Files, path, html! {
crates/cli/ents-web/src/pages/inbox.rs @@ -29,9 +29,10 @@ rows.push(rest.to_owned()); } } - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Inbox, + &super::identity_label(&state), + "/inbox", "inbox", crate::render::string_list(&rows, |_| "/inbox".to_owned()), ))
crates/cli/ents-web/src/pages/members.rs @@ -23,9 +23,10 @@ O: Find + Write + Send + 'static, { let rows = read_all(&state)?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Members, + &super::identity_label(&state), + "/members", "members", crate::render::list_table(&rows, "username", |id| format!("/members/{id}")), )) @@ -49,9 +50,10 @@ .ok_or_else(|| Error::NotFound { what: format!("member {username}"), })?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Members, + &super::identity_label(&state), + "/members", &username, crate::render::view(&member), ))
crates/cli/ents-web/src/pages/mod.rs @@ -9,7 +9,11 @@ //! [`toolchains`] and [`comments`] are legitimate custom pages //! (`ents-kiln`'s recipe provenance and `ents-forge`'s anchor projection //! both need domain-specific rendering no generic reflection walk should -//! grow special cases for). +//! grow special cases for). [`members`], [`effects`], [`toolchains`], +//! [`redactions`], and [`inbox`] additionally share one `meta` tab and +//! [`META_SECTIONS`] rail rather than each carrying its own top-level tab +//! (see [`Tab`]'s own doc); [`meta`] is that group's `GET /meta` landing +//! page. pub mod account; pub mod comments; @@ -18,6 +22,7 @@ pub mod files; pub mod inbox; pub mod members; +pub mod meta; pub mod redactions; pub mod toolchains; @@ -57,20 +62,71 @@ /// [`layout`]'s nav bar, so a handler can name which tab it renders behind /// without `layout` re-deriving it from the request path (mirrors /// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `Tab` enum, -/// trimmed to this crate's page families). +/// trimmed to four primary tabs). `Meta` covers five page families +/// ([`super::members`], [`super::effects`], [`super::toolchains`], +/// [`super::redactions`], [`super::inbox`]) behind one tab and the +/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than a tab each -- +/// nine equal tabs did not scale as page families grew. `Account` matches +/// none of [`layout`]'s tab-strip arms, so it highlights nothing there; +/// its own link lives in the header's `.id-chip` instead (see [`layout`]'s +/// own doc). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { - Dashboard, - Members, + Overview, Files, - Account, - Effects, - Redactions, - Toolchains, Comments, - Inbox, + Meta, + Account, } +/// One entry in the `meta` tab's registry: a page family reachable from +/// both [`meta::show`]'s index card and the `.meta-rail` every page in +/// that family renders beside its own content (see [`layout_meta`]). This +/// table is the entire registry -- growing the `meta` group means adding +/// one entry here, never touching [`layout`], [`crate::router`]'s route +/// table beyond the new route itself, or a per-page CSS hook. +pub(crate) struct MetaSection { + /// The section's name, shown as both the rail link text and the + /// `/meta` index card's link text. + pub(crate) name: &'static str, + /// The section's own list-page URL. A `/{id}` child page (e.g. + /// `/members/{username}`) highlights this same entry rather than + /// failing to match anything (see [`layout_meta`]'s own doc). + pub(crate) href: &'static str, + /// One line describing the section, shown only on the `/meta` index + /// card. + pub(crate) blurb: &'static str, +} + +/// The `meta` tab's registry (see [`MetaSection`]'s own doc). +pub(crate) const META_SECTIONS: &[MetaSection] = &[ + MetaSection { + name: "members", + href: "/members", + blurb: "Enrolled members and their signing keys.", + }, + MetaSection { + name: "effects", + href: "/effects", + blurb: "Registered effects and their trigger queries.", + }, + MetaSection { + name: "toolchains", + href: "/toolchains", + blurb: "Recorded toolchain recipes and their import provenance.", + }, + MetaSection { + name: "redactions", + href: "/redactions", + blurb: "Recorded redactions.", + }, + MetaSection { + name: "inbox", + href: "/inbox", + blurb: "Entries awaiting adoption.", + }, +]; + /// The served repository's identity for the shell's `.repo-header` /// breadcrumb band: its directory name and, when `HEAD` resolves to a /// branch, that branch's short name (mirrors @@ -115,8 +171,18 @@ /// through -- the pre-redo header bar, repo-header breadcrumb band, and tab /// nav (`pre-redo:crates/git-ents-server/src/web/style.css`'s `.site-nav`/ /// `.nav-search`/`.repo-header`/`.tabs` rules), `active` naming which tab -/// is current and `repo` the served repository the band names. -pub(crate) fn layout(repo: &RepoHeader, active: Tab, title: &str, body: Markup) -> Markup { +/// is current and `repo` the served repository the band names. `identity` +/// is the signing identity's display label (see [`identity_label`]), +/// rendered as the header's right-aligned `.id-chip` link to `/account` -- +/// account has no tab of its own (see [`Tab`]'s own doc), so this chip is +/// its only entry point from the shell. +pub(crate) fn layout( + repo: &RepoHeader, + identity: &str, + active: Tab, + title: &str, + body: Markup, +) -> Markup { html! { (maud::DOCTYPE) html lang="en" { @@ -125,9 +191,6 @@ meta name="viewport" content="width=device-width, initial-scale=1"; meta name="color-scheme" content="light dark"; title { "git ents: " (title) } - link rel="preconnect" href="https://fonts.googleapis.com"; - link rel="preconnect" href="https://fonts.gstatic.com" crossorigin; - link rel="stylesheet" href=(crate::assets::FONTS_HREF); link rel="stylesheet" href="/style.css"; } body { @@ -138,6 +201,7 @@ (crate::assets::icon_search()) input type="search" placeholder="Jump to file or symbol" aria-label="Search" disabled title="Not available yet"; } + a.id-chip href="/account" { (identity) } } } div.repo-header { @@ -152,15 +216,10 @@ } } nav.tabs { - a.tab.active[active == Tab::Dashboard] href="/" { "dashboard" } - a.tab.active[active == Tab::Members] href="/members" { "members" } + a.tab.active[active == Tab::Overview] href="/" { "overview" } a.tab.active[active == Tab::Files] href="/files" { "files" } - a.tab.active[active == Tab::Account] href="/account" { "account" } - a.tab.active[active == Tab::Effects] href="/effects" { "effects" } - a.tab.active[active == Tab::Redactions] href="/redactions" { "redactions" } - a.tab.active[active == Tab::Toolchains] href="/toolchains" { "toolchains" } a.tab.active[active == Tab::Comments] href="/comments" { "comments" } - a.tab.active[active == Tab::Inbox] href="/inbox" { "inbox" } + a.tab.active[active == Tab::Meta] href="/meta" { "meta" } } main.content { div.page-header { h1.page-title { (title) } } @@ -171,6 +230,49 @@ } } +/// Wrap `body` in the [`META_SECTIONS`] rail, then in [`layout`] itself +/// with `Meta` active -- the thin wrapper every meta-namespace page +/// ([`super::members`], [`super::effects`], [`super::toolchains`], +/// [`super::redactions`], [`super::inbox`]) calls instead of [`layout`] +/// directly, so the rail markup lives in exactly one place. `active_href` +/// names which [`META_SECTIONS`] entry to highlight -- a page family's own +/// `href`, not the request's actual path, so a `/{id}` child page (e.g. +/// `/members/{username}`) highlights the same rail entry as its list page. +pub(crate) fn layout_meta( + repo: &RepoHeader, + identity: &str, + active_href: &str, + title: &str, + body: Markup, +) -> Markup { + layout( + repo, + identity, + Tab::Meta, + title, + html! { + div.meta-layout { + nav.meta-rail { + @for section in META_SECTIONS { + a.active[section.href == active_href] href=(section.href) { (section.name) } + } + } + div { (body) } + } + }, + ) +} + +/// The signing identity's display label for [`layout`]'s `.id-chip` +/// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::actor`]'s +/// own author name, the cheapest accessor the trait exposes. Every page +/// reads this off `state` itself rather than `layout` reaching into +/// [`AppState`], so `layout` stays a pure function of the shell's own +/// chrome inputs (the same reason a [`Session`] is never threaded into it). +pub(crate) fn identity_label<O>(state: &AppState<O>) -> String { + state.identity.actor().name.to_string() +} + /// A hidden CSRF input every form this crate renders carries /// (`roots.web-session`): the one place that field is spelled, so a form /// can never omit it by a typo.
crates/cli/ents-web/src/pages/redactions.rs @@ -22,9 +22,10 @@ O: Find + Write + Send + 'static, { let rows = read_all(&state)?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Redactions, + &super::identity_label(&state), + "/redactions", "redactions", crate::render::list_table(&rows, "id", |id| format!("/redactions/{id}")), )) @@ -48,9 +49,10 @@ .ok_or_else(|| Error::NotFound { what: format!("redaction {id}"), })?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Redactions, + &super::identity_label(&state), + "/redactions", &id, crate::render::view(&redaction), ))
crates/cli/ents-web/src/pages/toolchains.rs @@ -28,9 +28,10 @@ O: Find + Write + Send + 'static, { let names = toolchain::list(state.refs.as_ref())?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Toolchains, + &super::identity_label(&state), + "/toolchains", "toolchains", crate::render::string_list(&names, |name| format!("/toolchains/{name}")), )) @@ -52,9 +53,10 @@ { let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?; let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?; - Ok(super::layout( + Ok(super::layout_meta( &super::RepoHeader::from_state(&state), - super::Tab::Toolchains, + &super::identity_label(&state), + "/toolchains", &name, html! { dl {
crates/cli/ents-web/src/pages/meta.rs @@ -1,0 +1,37 @@ +//! `GET /meta`: the landing page for the `meta` tab -- a card listing +//! every page family in [`super::META_SECTIONS`] with its blurb, so the +//! tab resolves to something other than an arbitrary pick of its five +//! children. The rail those children render beside their own content +//! (`super::layout_meta`) is this same table; this page is its index. + +use std::sync::Arc; + +use axum::extract::State; +use gix_object::{Find, Write}; +use maud::html; + +use crate::state::AppState; + +/// `GET /meta`. +pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> maud::Markup +where + O: Find + Write + Send + 'static, +{ + super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Meta, + "meta", + html! { + div.card { + div.card-header { "meta" } + @for section in super::META_SECTIONS { + div.card-row { + a href=(section.href) { (section.name) } + span { (section.blurb) } + } + } + } + }, + ) +}