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\ -
{brand}
\n\ -\n\ +
\"hu13hu13••{brand}
\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("b

", "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(" inside the header" + ); + // Brand cluster: logo img (alt text) + hu13 • … • repo brand. + assert!( + html.contains("hu13<"), "{page} names hu13"); + assert!(html.contains("•"), "{page} has • separators"); + assert!( + html.matches("•").count() >= 2, + "{page} separates brand/nav/repo with •" + ); + assert!( + html.contains(""), + "{page} keeps the repo brand" + ); + // Scrollable nav strip under its own class. + assert!( + html.contains("nav-scroll"), + "{page} nav has the scroll class" + ); + } +} + +#[test] +fn active_tab_marked_with_aria_current() { + let out = build_fixture(); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains(""), + "code tab active on index" + ); + let commits = std::fs::read_to_string(out.join("commits.html")).expect("commits"); + assert!( + commits.contains(""), + "commits tab active on commits.html" + ); + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues"); + assert!( + issues.contains(""), + "issues tab active on issues/index.html" + ); + // Exactly one active tab per page. + for (name, html) in [ + ("index", &index), + ("commits", &commits), + ("issues", &issues), + ] { + assert_eq!( + html.matches("aria-current=\"page\"").count(), + 1, + "{name} marks exactly one active tab" + ); + } +} + +#[test] +fn nav_scroll_css_scrolls_without_snap_and_shows_state() { + let css = css(); + // Own class (appended, earlier rules untouched). + assert!(css.contains(".nav-scroll"), "scroll class styled"); + // Scrollable strip: horizontal scroll + no wrapping. + assert!(css.contains("overflow-x:auto"), "nav scrolls horizontally"); + assert!(css.contains("white-space:nowrap"), "nav links never wrap"); + // Scroll snap explicitly off (the strip is a plain scroller). + assert!( + css.contains("scroll-snap-type:none"), + "snap off so the strip rests anywhere" + ); + // Position stays visible: focus ring + active accent underline. + assert!( + css.contains(":focus-visible"), + "keyboard focus stays visible" + ); + assert!( + css.contains("aria-current"), + "active tab styled via aria-current" + ); +} -- 2.55.0