roots: regroup the nav into four tabs with a registry-driven meta rail
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
No reviews of this commit yet — record a verdict below.
Start a review
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/tests/router.rs
@@ -640,6 +640,52 @@
assert!(body.contains("1 < 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/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/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) }
+ }
+ }
+ }
+ },
+ )
+}