roots: replace the tab shell with the workbench icon rail and top bar
commit
57b46dbroots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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>