From e43d297dcdde741492637659831f0382d17aed64 Mon Sep 17 00:00:00 2001
From: gl0w
Date: Thu, 10 Sep 2026 07:00:44 +0100
Subject: [PATCH] m6/navbar: one header bar, logo+bullets+scroll nav (145
tests)
---
src/layout.rs | 197 ++++++++++++++++++++++++++++++++++++++++++++++-
static/style.css | 17 ++++
tests/navbar.rs | 170 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 382 insertions(+), 2 deletions(-)
create mode 100644 tests/navbar.rs
diff --git a/src/layout.rs b/src/layout.rs
index 691a85f..fbecd44 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -46,6 +46,89 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {
/// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching
/// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`
/// link..
+/// Which nav `href` is active for a page (m6 navbar, f0rge issue #18).
+///
+/// `page_site` knows only `title`/`body`/`site_name`, so the mapping stays
+/// title-driven: exact section titles (`commits`, `issues`, …) map to their
+/// index hrefs, `commit ` details map to commits, `X — releases` /
+/// `X — wiki` details map to their indexes, `Suggest an issue`/`Suggest a
+/// change` map to issues/pulls, and `#N …` detail pages disambiguate via
+/// the body (`all issues` vs `all pulls`). File/dir pages (`{path} —
+/// {site}` with no section prefix) map to `/` (code); bare titles with no
+/// site suffix (health, 404s, inbox acks) map to nothing so no tab claims
+/// them. Returns the exact `href` to mark, or `None` for no active tab.
+#[must_use]
+pub fn active_href(title: &str, body: &str, site_name: &str) -> Option<&'static str> {
+ if title == site_name {
+ return Some("/");
+ }
+ let suffix = format!(" — {site_name}");
+ let base = title.strip_suffix(&suffix).unwrap_or(title);
+ if base == "commits" || base == "commit" || base.starts_with("commit ") {
+ return Some("/commits.html");
+ }
+ if base == "issues" {
+ return Some("/issues/index.html");
+ }
+ if base == "pulls" {
+ return Some("/pulls/index.html");
+ }
+ if base == "tasks" {
+ return Some("/tasks/index.html");
+ }
+ if base == "ci" {
+ return Some("/ci/index.html");
+ }
+ if base == "releases" || base.ends_with(" — releases") {
+ return Some("/releases/index.html");
+ }
+ if base == "wiki" || base.ends_with(" — wiki") {
+ return Some("/wiki/index.html");
+ }
+ if base == "agents" {
+ return Some("/agents.html");
+ }
+ if base == "Suggest an issue" {
+ return Some("/issues/index.html");
+ }
+ if base == "Suggest a change" {
+ return Some("/pulls/index.html");
+ }
+ if base.starts_with('#') {
+ if body.contains("all pulls") {
+ return Some("/pulls/index.html");
+ }
+ if body.contains("all issues") {
+ return Some("/issues/index.html");
+ }
+ return None;
+ }
+ // File/dir pages carry the site suffix but matched no section above.
+ if title.ends_with(&suffix) {
+ return Some("/");
+ }
+ None
+}
+
+/// Inject `aria-current="page"` into the first nav link matching `href`
+/// (m6 navbar, f0rge issue #18). Leaves nav untouched when it already
+/// carries an `aria-current` or lacks the href (unit-test stubs, 404s).
+#[must_use]
+pub fn apply_active(nav: &str, href: &str) -> String {
+ if nav.contains("aria-current") {
+ return nav.to_string();
+ }
+ let needle = format!(" String {
let title_e = esc(title);
let brand = esc(site_name);
+ // One-bar header (m6 navbar, f0rge issue #18): the active link (if
+ // any) gets `aria-current="page"` before the nav is inlined. The
+ // favicon doubles as the logo mark (byte-identical to `logo.svg`).
let mut head = format!(
"\n\
\n\
@@ -78,13 +164,16 @@ pub fn page_site(
\n"
));
}
+ let nav_active = match active_href(title, body, site_name) {
+ Some(href) => apply_active(nav, href),
+ None => nav.to_string(),
+ };
format!(
"\n\
\n\
\n{head}\n\
\n\
-\n\
-\n\
+\n\
\n{body}\n\n\
\n\
\n"
@@ -156,4 +245,108 @@ mod tests {
assert!(html.contains(needle), "head carries {needle}");
}
}
+
+ #[test]
+ fn header_is_one_bar_with_logo_seps_brand_and_scroll_nav() {
+ // m6 navbar (f0rge issue #18): a single holds logo +
+ // hu13 • scrollable nav • repo brand; no second top-level nav.
+ use super::{active_href, apply_active, page_site};
+ let nav = "code · commits";
+ let html = page_site("demo", nav, "b
", "demo", None);
+ assert_eq!(html.matches("hu13"),
+ "hu13 word"
+ );
+ assert!(html.matches("•").count() >= 2, "bullet separators");
+ assert!(
+ html.contains("demo"),
+ "repo brand kept"
+ );
+ assert!(html.contains("nav-scroll"), "nav has scroll class");
+ assert!(html.contains("
", "demo"), Some("/"));
+ assert_eq!(
+ active_href("commits — demo", "", "demo"),
+ Some("/commits.html")
+ );
+ assert_eq!(active_href("health", "", "hu13"), None);
+ let marked = apply_active(nav, "/commits.html");
+ assert!(
+ marked.contains(""),
+ "aria-current injected, got: {marked}"
+ );
+ }
+
+ #[test]
+ fn active_tab_marks_each_section_and_file_pages_are_code() {
+ use super::{active_href, apply_active, page_site};
+ let nav = concat!(
+ "code · ",
+ "commits · ",
+ "issues · ",
+ "pulls · ",
+ "tasks · ",
+ "ci · ",
+ "releases · ",
+ "wiki · ",
+ "agents",
+ );
+ for (title, body, want) in [
+ ("s", "x
", "/"),
+ ("notes.txt — s", "f
", "/"),
+ ("commits — s", "", "/commits.html"),
+ ("commit abc1234 — s", "", "/commits.html"),
+ ("issues — s", "", "/issues/index.html"),
+ ("pulls — s", "", "/pulls/index.html"),
+ ("tasks — s", "", "/tasks/index.html"),
+ ("ci — s", "", "/ci/index.html"),
+ ("releases — s", "", "/releases/index.html"),
+ ("v1 — releases — s", "", "/releases/index.html"),
+ ("wiki — s", "", "/wiki/index.html"),
+ ("Guide — wiki — s", "", "/wiki/index.html"),
+ ("agents — s", "", "/agents.html"),
+ ] {
+ let site = "s";
+ let title_full = if title == "s" {
+ site.to_string()
+ } else {
+ title.to_string()
+ };
+ assert_eq!(
+ active_href(&title_full, body, site),
+ Some(want),
+ "active for {title_full:?}"
+ );
+ let html = page_site(&title_full, nav, body, site, None);
+ assert_eq!(html.matches("aria-current=\"page\"").count(), 1);
+ assert!(html.contains(&format!("")));
+ }
+ // `#N` detail pages disambiguate through the body footer links.
+ assert_eq!(
+ active_href(
+ "#3 Foo — s",
+ "all issues",
+ "s"
+ ),
+ Some("/issues/index.html")
+ );
+ assert_eq!(
+ active_href(
+ "#4 Bar — s",
+ "all pulls",
+ "s"
+ ),
+ Some("/pulls/index.html")
+ );
+ // Bare error/health titles claim no tab; pre-marked nav is kept.
+ assert_eq!(active_href("not found", "x
", "hu13"), None);
+ let pre = "code";
+ assert_eq!(apply_active(pre, "/"), pre);
+ }
}
diff --git a/static/style.css b/static/style.css
index 8a0e3b2..2dbc66c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -116,6 +116,23 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}
.btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}
.muted{color:var(--muted)}
footer.site-footer .wrap{padding:1rem}
+/* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.
+ Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/
+ .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav
+ strip scrolls horizontally on narrow (no wrap, snap off) with a visible
+ focus ring; the active link gets an accent underline via aria-current. */
+.site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}
+.brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}
+.brand-lockup:visited{color:var(--fg)}
+.site-logo{display:block;width:1.5rem;height:1.5rem;flex:none}
+.hu13-word{font-weight:700;color:var(--blue)}
+.sep{flex:none;color:var(--muted)}
+.site-header .site-nav.nav-scroll{flex:1 1 auto;min-width:0;background:none;border-bottom:none;overflow-x:auto;scroll-snap-type:none;-webkit-overflow-scrolling:touch}
+.nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}
+.site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}
+.site-header .site-nav.nav-scroll a[aria-current="page"]{font-weight:700;text-decoration:underline;text-decoration-color:var(--blue);text-underline-offset:.25em}
+.brand-lockup:focus-visible,.site-header .site-nav.nav-scroll a:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
+.site-header .brand{flex:none}
@media (min-width:768px){
.cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}
.site-nav .wrap{gap:.25rem 1.25rem}
diff --git a/tests/navbar.rs b/tests/navbar.rs
new file mode 100644
index 0000000..701a9d9
--- /dev/null
+++ b/tests/navbar.rs
@@ -0,0 +1,170 @@
+//! Navbar one-bar (m6, f0rge issue #18):
+//! single `` (`logo hu13 • nav • repo`), scrollable nav strip
+//! (own `nav-scroll` class: `overflow-x:auto` + nowrap, snap off, visible
+//! focus), logo img alt text, `aria-current` on the active tab.
+//!
+//! Builds a small fixture repo with the real binary and string-asserts on
+//! the emitted HTML + `static/style.css` (no rendering engine needed).
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn bin() -> &'static str {
+ env!("CARGO_BIN_EXE_hu13")
+}
+
+fn manifest() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+fn fresh(prefix: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "hu13-navbar-{prefix}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+fn git(repo: &Path, args: &[&str]) {
+ let st = Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .expect("spawn git");
+ assert!(st.success(), "git {args:?} failed");
+}
+
+fn fixture_repo() -> PathBuf {
+ let dir = fresh("repo");
+ git(&dir, &["init", "-b", "main"]);
+ git(&dir, &["config", "user.name", "hu13-test"]);
+ git(&dir, &["config", "user.email", "hu13@test.local"]);
+ git(&dir, &["config", "commit.gpgsign", "false"]);
+ std::fs::write(dir.join("README.md"), "# navbar fixture\n\nhello\n").unwrap();
+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
+ git(&dir, &["add", "-A"]);
+ git(&dir, &["commit", "-m", "init"]);
+ dir
+}
+
+fn build_fixture() -> PathBuf {
+ let repo = fixture_repo();
+ let out = fresh("out");
+ let st = Command::new(bin())
+ .arg("build")
+ .arg(&repo)
+ .arg("-o")
+ .arg(&out)
+ .status()
+ .expect("spawn hu13 build");
+ assert!(st.success(), "fixture build exits 0");
+ out
+}
+
+fn css() -> String {
+ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")
+}
+
+#[test]
+fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {
+ let out = build_fixture();
+ for page in ["index.html", "commits.html", "issues/index.html"] {
+ let html = std::fs::read_to_string(out.join(page)).expect(page);
+ // One header bar on every page; the nav lives inside it.
+ assert_eq!(
+ html.matches(""
+ );
+ assert!(
+ html.contains("