git-ents.gitmain
⌘K
foforge
commit 57b46db
roots: replace the tab shell with the workbench icon rail and top bar

The six-tab strip and site-nav/repo-header bands become the Proposal C workbench chrome: a sticky icon rail (Dashboard, Code, Review, Tickets, Threads; governance and account past the spacer) beside a wb-main column whose sticky top bar carries the repo name, branch pill, the palette-styled search form (still a plain GET to /search), and the identity chip. Icons come from one embedded SVG symbol sprite; every URL path stays stable.

Assisted-by: Claude:claude-fable-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 @@ -14,11 +14,14 @@ //! The icon functions below are vendored Octicons (`.gitvendors`, MIT; see //! `assets/icons/LICENSE`), re-homed here from //! `pre-redo:crates/git-ents-server/src/web/icons/` for -//! [`crate::pages::files`]'s directory listing and breadcrumbs and for the -//! shell chrome [`crate::pages::layout`] draws (the `nav.site-nav` search -//! stub and the `.repo-header` branch pill) -- the same +//! [`crate::pages::files`]'s directory listing and breadcrumbs -- the same //! `include_str!`-and-tag pattern -//! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. +//! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. The workbench +//! shell's own chrome (the `.rail` page-family icons, the `.palette` +//! search glass, the `.branch` pill) draws from [`sprite`] instead: one +//! hand-rolled `<symbol>` sprite embedded per page by +//! `crate::pages::layout`, each use site a tiny [`icon_use`] reference +//! rather than a repeated inline SVG. use std::sync::LazyLock; @@ -57,6 +60,22 @@ icon_folder => "file-directory-fill", icon_file => "file", icon_chevron => "chevron-right", - icon_search => "search", - icon_branch => "git-branch", +} + +/// The workbench shell's inline `<symbol>` sprite (see this module's own +/// doc) -- embedded once per page, right after `<body>`, so every +/// [`icon_use`] reference on the page resolves against it. +pub(crate) fn sprite() -> Markup { + PreEscaped(include_str!("assets/sprite.svg").to_owned()) +} + +/// An `.icon`-classed, decorative `<use>` reference into [`sprite`] -- +/// `id` names one of its `<symbol>`s (`i-home`, `i-files`, ...). Sized +/// entirely by the use site's own CSS rule (`.rail a .icon`, +/// `.palette .icon`, `.branch .icon`), since a symbol carries only a +/// viewBox. +pub(crate) fn icon_use(id: &str) -> Markup { + PreEscaped(format!( + "<svg class=\"icon\" aria-hidden=\"true\"><use href=\"#{id}\"/></svg>" + )) }
crates/cli/ents-web/tests/router.rs @@ -429,20 +429,21 @@ let body = String::from_utf8(body.to_vec()).expect("utf8 html"); assert!(body.contains("members")); assert!(body.contains("toolchains")); - // The shell chrome renders on every page: the nav search form in the - // top nav and the repo-header breadcrumb band above the tabs. - assert!(body.contains("nav-search")); - assert!(body.contains("Jump to file or symbol")); - assert!(body.contains("repo-header")); + // The shell chrome renders on every page: the icon rail, the sticky + // top bar, and the bar's palette search form. + assert!(body.contains("class=\"rail\"")); + assert!(body.contains("class=\"wb-bar\"")); + assert!(body.contains("class=\"palette\"")); + assert!(body.contains("Jump to file, commit, ticket, member")); } -/// `roots.web-agnostic`: the shell's `.repo-header` band names the served +/// `roots.web-agnostic`: the shell's `.wb-bar` top bar names the served /// repository (its directory name) and, when `HEAD` resolves to a branch, /// renders that branch in the `.branch` pill -- both read once off /// `AppState.path`, so every page's chrome reflects the actual repository /// being served rather than a placeholder. #[tokio::test] -async fn repo_header_names_the_served_repo_and_its_head_branch() { +async fn the_top_bar_names_the_served_repo_and_its_head_branch() { let dir = seed_repo(&[("README.md", "# hi\n")]); // `git init` picks the default branch name (which varies by host git // config); rename it so the pill's text is deterministic to assert. @@ -481,10 +482,10 @@ .expect("body") .to_bytes(); let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!(body.contains("repo-header")); + assert!(body.contains("class=\"wb-bar\"")); assert!( body.contains(&repo_name), - "the served repo's directory name {repo_name:?} must appear as the breadcrumb crumb" + "the served repo's directory name {repo_name:?} must appear in the top bar" ); assert!( body.contains("class=\"branch\""), @@ -966,13 +967,13 @@ assert!(body.contains("class=\"keyword\"")); } -/// The tab strip (`crate::pages::layout`) names every top-level page -/// family truthfully: six tabs -- overview, files, commits, issues, -/// comments, meta -- and the issues family no longer renders behind the -/// `META_SECTIONS` rail (it is a tab of its own; see `crate::pages::mod`'s +/// The icon rail (`crate::pages::layout_shell`) names every top-level page +/// family truthfully: Dashboard, Code, Review, Tickets, Threads, then the +/// meta and account items -- and the issues family renders as its own rail +/// item, never behind the `META_SECTIONS` rail (see `crate::pages::mod`'s /// own doc). #[tokio::test] -async fn the_tab_strip_carries_all_six_tabs_and_issues_left_the_meta_rail() { +async fn the_rail_carries_every_page_family_and_issues_left_the_meta_rail() { let state = build_state(FixtureIdentity { name: "local-user", key: Keypair::from_seed(1), @@ -980,22 +981,36 @@ let router = ents_web::router(state); let overview = get_body(&router, "/").await; - for href in ["/", "/files", "/commits", "/issues", "/comments", "/meta"] { + for href in [ + "/", + "/files", + "/commits", + "/issues", + "/comments", + "/meta", + "/account", + ] { assert!( overview.contains(&format!("href=\"{href}\"")), - "the tab strip links {href}" + "the rail links {href}" + ); + } + for label in ["Dashboard", "Code", "Review", "Tickets", "Threads"] { + assert!( + overview.contains(&format!("title=\"{label}\"")), + "the rail tooltips {label}" ); } let issues = get_body(&router, "/issues").await; assert!( !issues.contains("class=\"meta-rail\""), - "issues renders as its own tab, not behind the meta rail" + "issues renders as its own rail item, not behind the meta rail" ); - // The rail renders a bare (classless when inactive) link per section; - // the tab strip's own issues link always carries `class="tab..."`, so - // this exact form only ever comes from the rail. + // The meta rail renders a bare (classless when inactive) link per + // section; the icon rail's own issues link always carries a `title` + // attribute, so this exact form only ever comes from the meta rail. let members = get_body(&router, "/members").await; assert!( !members.contains("<a href=\"/issues\">issues</a>"), @@ -1003,11 +1018,48 @@ ); } -/// 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. +/// The rail highlights exactly the item whose page family is being viewed +/// (`crate::pages::rail_link`'s `active` toggle): on `GET /files` the Code +/// item carries `class="active"` and the others do not. +#[tokio::test] +async fn the_rail_marks_the_active_item() { + // A real on-disk repository: `GET /files` opens `state.path` itself. + let dir = seed_repo(&[("README.md", "# hi\n")]); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state); + + let files = get_body(&router, "/files").await; + assert!( + files.contains("class=\"active\" href=\"/files\""), + "the Code item highlights on a files page" + ); + assert!( + files.contains("class=\"\" href=\"/commits\""), + "the Review item stays unhighlighted there" + ); + + let comments = get_body(&router, "/comments").await; + assert!( + comments.contains("class=\"active\" href=\"/comments\""), + "the Threads item highlights on the comments page" + ); + assert!( + comments.contains("class=\"\" href=\"/files\""), + "the Code item stays unhighlighted there" + ); +} + +/// The `meta` group (`crate::pages::mod`'s own doc): `GET /meta` is +/// reachable as the group's index page, and `GET /members` -- one of the +/// five page families that group shares -- renders with the +/// `META_SECTIONS` rail visible and the icon rail's meta item (not a +/// per-family item) highlighted. #[tokio::test] async fn meta_index_and_a_meta_group_page_render_with_the_rail() { let state = build_state(FixtureIdentity { @@ -1044,8 +1096,8 @@ "a meta-group page renders the section rail" ); assert!( - body.contains("class=\"tab active\""), - "the meta tab itself highlights, not a per-family tab" + body.contains("class=\"active\" href=\"/meta\""), + "the rail's meta item itself highlights, not a per-family item" ); }
crates/cli/ents-web/src/assets/ents.css @@ -11,10 +11,11 @@ * account-strip sign-in). 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 + * pre-redo sheet: the `.wb`/`.rail`/`.wb-bar`/`.palette` workbench shell + * (`crate::pages::layout_shell`, adapted from the design project's + * `proposal-c.css`); `.meta-layout`/`.meta-rail` for the `meta` group'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 tab - * restructure; `.comment-meta`/`.outdated` for the file-anchored + * bar's signing-identity link; `.comment-meta`/`.outdated` for the file-anchored * comment cards `crate::pages::comments::comments_section` renders below a * blob view, which otherwise reuse `.card`/`.doc-body` as-is; and * `.blob-header`/`.blob-actions`/`.entry-size` for @@ -99,46 +100,40 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; } .icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; } -/* Header band: site wordmark, then the tab-style page nav. */ -.site-nav { position: sticky; top: 0; z-index: 100; background: color-mix(in srgb, var(--color-bg) 82%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--color-border); } -.nav-inner { max-width: var(--max-width); margin: 0 auto; height: 58px; padding: 0 1.5rem; display: flex; align-items: center; gap: 1.25rem; } -.nav-logo { display: inline-flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-weight: 700; font-size: 1.02rem; color: var(--color-text); letter-spacing: -.01em; text-decoration: none; white-space: nowrap; transition: color .15s; } -.nav-mark { color: var(--color-accent); font-size: 1.1rem; } -.nav-logo:hover { color: var(--color-accent); } -.nav-search { flex: 1; max-width: 24rem; margin: 0 auto; position: relative; display: flex; align-items: center; } -.nav-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); pointer-events: none; } -.nav-search input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; transition: border-color .15s; } -.nav-search input:focus { border-color: var(--color-accent); } -.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, - * trimmed to this single-repo crate's name + branch). */ -.repo-header { max-width: var(--max-width); margin: 0 auto; padding: 1.25rem 1.5rem .85rem; display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap; } -.repo-headline { flex: 1; min-width: 0; } -.repo-path { font-family: var(--font-mono); font-size: 1.18rem; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; word-break: break-all; } -.repo-path .icon { color: var(--color-accent); } +/* The workbench shell (`crate::pages::layout_shell`, the "Proposal C" + * chrome): a `.wb` grid pairing the sticky icon `.rail` with a `.wb-main` + * column whose sticky `.wb-bar` top bar carries the repo name, branch + * pill, `.palette` search, and `.id-chip` identity link. */ +.wb { display: grid; grid-template-columns: 56px minmax(0, 1fr); min-height: 100vh; width: 100%; } +.rail { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: .3rem; padding: .8rem 0; border-right: 1px solid var(--color-border); background: var(--color-surface); } +.rail .nav-mark { font-size: 1.35rem; margin-bottom: .6rem; } +.nav-mark { color: var(--color-accent); } +.rail a { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: var(--color-text-muted); } +.rail a:hover { background: var(--color-code-bg); color: var(--color-text); } +.rail a.active { background: var(--color-accent-subtle); color: var(--color-accent); } +.rail a .icon { width: 17px; height: 17px; } +.rail .spacer { flex: 1; } +.wb-main { min-width: 0; display: flex; flex-direction: column; } +.wb-bar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 1rem; height: 52px; padding: 0 1.25rem; border-bottom: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-bg) 90%, transparent); backdrop-filter: blur(10px); } +.repo-path { font-family: var(--font-mono); font-size: .98rem; display: flex; align-items: center; gap: .5rem; white-space: nowrap; min-width: 0; } .repo-path .here { color: var(--color-accent); font-weight: 600; } .branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; display: inline-flex; align-items: center; gap: .3rem; } .branch .icon { width: 13px; height: 13px; color: var(--color-accent); } - -/* `overflow-x: auto` keeps the strip scrollable on a narrow viewport; - * `scrollbar-width: none` (and the WebKit pseudo-element below) hides the - * scrollbar itself, which otherwise renders as a stray artifact under the - * strip on macOS. */ -.tabs { display: flex; gap: .15rem; max-width: var(--max-width); margin: 0 auto; padding: 0 1.5rem; border-bottom: 1px solid var(--color-border); overflow-x: auto; scrollbar-width: none; } -.tabs::-webkit-scrollbar { display: none; } -.tab { display: inline-flex; align-items: center; gap: .4rem; padding: 10px 14px; font-size: .88rem; color: var(--color-text-muted); text-decoration: none; white-space: nowrap; position: relative; transition: color .15s; } -.tab:hover { color: var(--color-text); text-decoration: none; } -.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); } +/* `.palette` is a `form`, so it must undo the stacked-field form rule + * below (column direction, row gap, bottom margin). */ +.palette { flex: 0 1 26rem; margin-left: auto; position: relative; display: flex; flex-direction: row; align-items: center; gap: 0; margin-bottom: 0; } +.palette .icon { position: absolute; left: .65rem; width: 14px; height: 14px; color: var(--color-text-muted); pointer-events: none; } +.palette input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem 3rem .42rem 2rem; transition: border-color .15s; } +.palette input:focus { border-color: var(--color-accent); } +.palette kbd { position: absolute; right: .55rem; font-family: var(--font-mono); font-size: .66rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: 5px; padding: 0 .35rem; background: var(--color-code-bg); } +.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; } /* 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 { display: flex; flex-direction: column; gap: .1rem; position: sticky; top: 64px; } .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); } @@ -180,7 +175,7 @@ * of a contents rail and a language breakdown (pre-redo:.../style.css's * `.overview`/`.aside`/`.lang`). */ .overview { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 34px; align-items: start; } -.aside { position: sticky; top: 78px; display: flex; flex-direction: column; gap: 18px; min-width: 0; } +.aside { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 18px; min-width: 0; } .aside .card { margin-bottom: 0; } .aside-row { display: flex; align-items: center; gap: .5rem; padding: .55rem 1.1rem; font-size: .82rem; } .aside-row + .aside-row { border-top: 1px solid var(--color-border); } @@ -459,9 +454,9 @@ html { font-size: 16px; } .content { padding: 1.5rem 1.25rem 2.5rem; } form label { grid-template-columns: minmax(0, 1fr); } - .nav-inner { gap: .75rem; padding: 0 1.25rem; } - .repo-header { padding: 1rem 1.25rem .75rem; } - .tabs { padding: 0 1.25rem; } + .wb-bar { gap: .6rem; padding: 0 .9rem; } + .palette { flex-basis: 14rem; } + .palette kbd { display: none; } .page-title { font-size: 1.3rem; } .doc-body { padding: 1.75rem 1.5rem 2rem; } .doc-body h1 { font-size: 1.9rem; }
crates/cli/ents-web/src/pages/mod.rs @@ -11,13 +11,14 @@ //! and issue threads all need domain-specific rendering no generic //! reflection walk should 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. [`commits`] and [`issues`] are tabs of their -//! own -- `Tab::Commits` and `Tab::Issues` in [`layout`]'s six-tab strip, -//! alongside overview, files, comments, and meta. [`search`] renders with -//! no tab active at all, like [`account`]; it is reached from `layout`'s -//! own nav search form rather than any tab. +//! share one `meta` rail item and `META_SECTIONS` rail rather than each +//! carrying its own top-level entry (see `Tab`'s own doc); [`meta`] is that +//! group's `GET /meta` landing page. [`commits`] and [`issues`] are rail +//! items of their own -- `Tab::Commits` (Review) and `Tab::Issues` +//! (Tickets) in [`layout`]'s icon rail, alongside the dashboard, code, +//! threads, and meta items. [`search`] renders with no rail item active at +//! all; it is reached from the `.wb-bar`'s own `.palette` search form +//! rather than any rail item. pub mod account; pub mod comments; @@ -101,21 +102,21 @@ Ok((author.name.to_str_lossy().into_owned(), seconds)) } -/// The tab-nav page families this crate exposes -- one variant per tab in -/// [`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, -/// at this crate's own six primary tabs: overview, files, commits, issues, -/// comments, meta). `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). `None` is the same "highlights nothing" case for a page that -/// is not part of any tab's own section at all ([`super::search`]'s -/// results page). +/// The rail-nav page families this crate exposes -- one variant per icon +/// in [`layout`]'s `.rail`, so a handler can name which rail item it +/// renders behind without `layout` re-deriving it from the request path +/// (the pre-redo `Tab` enum, carried through the workbench restructure: +/// the horizontal tab strip became the vertical icon rail, but the +/// "handler names its own section" contract is unchanged). The rail reads, +/// top to bottom: Dashboard (`Overview`), Code (`Files`), Review +/// (`Commits`), Tickets (`Issues`), Threads (`Comments`); then, past the +/// spacer, Repo & governance (`Meta`) and Account. `Meta` covers five page +/// families ([`super::members`], [`super::effects`], [`super::toolchains`], +/// [`super::redactions`], [`super::inbox`]) behind one rail item and the +/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than an item each -- +/// nine equal entries did not scale as page families grew. `None` +/// highlights nothing at all, for a page that is not part of any rail +/// item's own section ([`super::search`]'s results page). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { Overview, @@ -176,8 +177,8 @@ }, ]; -/// The served repository's identity for the shell's `.repo-header` -/// breadcrumb band: its directory name and, when `HEAD` resolves to a +/// The served repository's identity for the shell's `.wb-bar` top bar: +/// its directory name and, when `HEAD` resolves to a /// branch, that branch's short name (mirrors /// `pre-redo:crates/git-ents-server/src/web/mod.rs`'s `RepoMeta`, trimmed /// to the two fields this single-repo crate actually has a data surface @@ -217,20 +218,60 @@ } /// Wrap `title` and `body` in the one page shell every route renders -/// 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. `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. +/// through -- the workbench chrome (see [`layout_shell`]) around a +/// `main.content` column carrying the page's own `.page-header` title and +/// `body`. `active` names which rail item is current and `repo` the served +/// repository the top bar names. `identity` is the signing identity's +/// display label (see [`identity_label`]), rendered as the bar's +/// right-aligned `.id-chip` link to `/account` -- the same place the +/// rail's own account icon leads. pub(crate) fn layout( repo: &RepoHeader, identity: &str, active: Tab, title: &str, body: Markup, +) -> Markup { + layout_shell( + repo, + identity, + active, + title, + html! { + main.content { + div.page-header { h1.page-title { (title) } } + (body) + } + }, + ) +} + +/// One `.rail` item: an icon-only link into a page family, `title`-tipped +/// (the rail carries no text labels at all), highlighted when `tab` is the +/// page's own `active` section. +fn rail_link(active: Tab, tab: Tab, href: &str, title: &str, icon: &str) -> Markup { + html! { + a.active[active == tab] href=(href) title=(title) { (crate::assets::icon_use(icon)) } + } +} + +/// The workbench shell itself (the "Proposal C" chrome, +/// `docs/web-workbench-plan.adoc`): a `.wb` grid pairing the sticky icon +/// `.rail` (Dashboard / Code / Review / Tickets / Threads, then governance +/// and account past the spacer -- see [`Tab`]'s own doc) with a `.wb-main` +/// column whose sticky `.wb-bar` top bar names the served repository and +/// its branch pill, carries the `.palette` search form (a plain GET to +/// `/search` for now -- the `⌘K` kbd is a hint at the palette phase, not +/// yet wired), and ends in the `.id-chip` identity link. `content` renders +/// below the bar as-is: [`layout`] passes the ordinary padded +/// `main.content` column, while a master-detail page passes its own +/// full-bleed `.split` instead. +pub(crate) fn layout_shell( + repo: &RepoHeader, + identity: &str, + active: Tab, + title: &str, + content: Markup, ) -> Markup { html! { (maud::DOCTYPE) @@ -244,39 +285,37 @@ script src="/ents.js" defer {} } body { - nav.site-nav { - div.nav-inner { - a.nav-logo href="/" { span.nav-mark { "✳" } "git-ents" } - form.nav-search method="get" action="/search" { - (crate::assets::icon_search()) - input type="search" name="q" placeholder="Jump to file or symbol" aria-label="Search"; - } - a.id-chip href="/account" { (identity) } + (crate::assets::sprite()) + div.wb { + aside.rail { + span.nav-mark { "✳" } + (rail_link(active, Tab::Overview, "/", "Dashboard", "i-home")) + (rail_link(active, Tab::Files, "/files", "Code", "i-files")) + (rail_link(active, Tab::Commits, "/commits", "Review", "i-commit")) + (rail_link(active, Tab::Issues, "/issues", "Tickets", "i-issue")) + (rail_link(active, Tab::Comments, "/comments", "Threads", "i-comment")) + span.spacer {} + (rail_link(active, Tab::Meta, "/meta", "Repo & governance", "i-meta")) + (rail_link(active, Tab::Account, "/account", "Account", "i-person")) } - } - div.repo-header { - div.repo-headline { - div.repo-path { - (crate::assets::icon_folder()) - span.here { (repo.name) } - @if let Some(branch) = &repo.branch { - span.branch { (crate::assets::icon_branch()) (branch) } + div.wb-main { + div.wb-bar { + span.repo-path { + span.here { (repo.name) } + @if let Some(branch) = &repo.branch { + span.branch { (crate::assets::icon_use("i-commit")) (branch) } + } } + form.palette method="get" action="/search" { + (crate::assets::icon_use("i-search")) + input type="search" name="q" placeholder="Jump to file, commit, ticket, member…" aria-label="Search"; + kbd { "⌘K" } + } + a.id-chip href="/account" { (identity) } } + (content) } } - nav.tabs { - a.tab.active[active == Tab::Overview] href="/" { "overview" } - a.tab.active[active == Tab::Files] href="/files" { "files" } - a.tab.active[active == Tab::Commits] href="/commits" { "commits" } - a.tab.active[active == Tab::Issues] href="/issues" { "issues" } - a.tab.active[active == Tab::Comments] href="/comments" { "comments" } - a.tab.active[active == Tab::Meta] href="/meta" { "meta" } - } - main.content { - div.page-header { h1.page-title { (title) } } - (body) - } } } }
crates/cli/ents-web/src/assets/sprite.svg @@ -1,0 +1,12 @@ +<svg style="display:none" xmlns="http://www.w3.org/2000/svg"> + <symbol id="i-home" viewBox="0 0 16 16"><path d="M2 7.5 8 2l6 5.5V14H9.5v-3.5h-3V14H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> + <symbol id="i-files" viewBox="0 0 16 16"><path d="M1.5 3.5h4l1.5 2h7.5v7h-13z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> + <symbol id="i-commit" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M.5 8h4.9M10.6 8h4.9" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-issue" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor"/></symbol> + <symbol id="i-comment" viewBox="0 0 16 16"><path d="M2 2.5h12v8H8l-3.5 3v-3H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> + <symbol id="i-meta" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 1.2v2.2M8 12.6v2.2M1.2 8h2.2M12.6 8h2.2M3.5 3.5l1.5 1.5M11 11l1.5 1.5M12.5 3.5 11 5M5 11l-1.5 1.5" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-people" viewBox="0 0 16 16"><circle cx="5.5" cy="5.5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1.5 14a4 4 0 0 1 8 0" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11.5" cy="6.5" r="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 14a3.5 3.5 0 0 1 3.8-3.2" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-person" viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2.5 14a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-search" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 10.5 14 14" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-menu" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5"/></symbol> +</svg>