#20 m6/navbar
Patch
src/layout.rs+195 −2--- a/src/layout.rs+++ b/src/layout.rs@@ -46,6 +46,89 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {4646 /// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching4747 /// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`4848 /// link..49+/// Which nav `href` is active for a page (m6 navbar, f0rge issue #18).50+///51+/// `page_site` knows only `title`/`body`/`site_name`, so the mapping stays52+/// title-driven: exact section titles (`commits`, `issues`, …) map to their53+/// index hrefs, `commit <sha>` details map to commits, `X — releases` /54+/// `X — wiki` details map to their indexes, `Suggest an issue`/`Suggest a55+/// change` map to issues/pulls, and `#N …` detail pages disambiguate via56+/// the body (`all issues` vs `all pulls`). File/dir pages (`{path} —57+/// {site}` with no section prefix) map to `/` (code); bare titles with no58+/// site suffix (health, 404s, inbox acks) map to nothing so no tab claims59+/// them. Returns the exact `href` to mark, or `None` for no active tab.60+#[must_use]61+pub fn active_href(title: &str, body: &str, site_name: &str) -> Option<&'static str> {62+ if title == site_name {63+ return Some("/");64+ }65+ let suffix = format!(" — {site_name}");66+ let base = title.strip_suffix(&suffix).unwrap_or(title);67+ if base == "commits" || base == "commit" || base.starts_with("commit ") {68+ return Some("/commits.html");69+ }70+ if base == "issues" {71+ return Some("/issues/index.html");72+ }73+ if base == "pulls" {74+ return Some("/pulls/index.html");75+ }76+ if base == "tasks" {77+ return Some("/tasks/index.html");78+ }79+ if base == "ci" {80+ return Some("/ci/index.html");81+ }82+ if base == "releases" || base.ends_with(" — releases") {83+ return Some("/releases/index.html");84+ }85+ if base == "wiki" || base.ends_with(" — wiki") {86+ return Some("/wiki/index.html");87+ }88+ if base == "agents" {89+ return Some("/agents.html");90+ }91+ if base == "Suggest an issue" {92+ return Some("/issues/index.html");93+ }94+ if base == "Suggest a change" {95+ return Some("/pulls/index.html");96+ }97+ if base.starts_with('#') {98+ if body.contains("all pulls") {99+ return Some("/pulls/index.html");100+ }101+ if body.contains("all issues") {102+ return Some("/issues/index.html");103+ }104+ return None;105+ }106+ // File/dir pages carry the site suffix but matched no section above.107+ if title.ends_with(&suffix) {108+ return Some("/");109+ }110+ None111+}112+113+/// Inject `aria-current="page"` into the first nav link matching `href`114+/// (m6 navbar, f0rge issue #18). Leaves nav untouched when it already115+/// carries an `aria-current` or lacks the href (unit-test stubs, 404s).116+#[must_use]117+pub fn apply_active(nav: &str, href: &str) -> String {118+ if nav.contains("aria-current") {119+ return nav.to_string();120+ }121+ let needle = format!("<a href=\"{href}\"");122+ if let Some(pos) = nav.find(&needle) {123+ let mut out = String::with_capacity(nav.len() + 22);124+ out.push_str(&nav[..pos + needle.len()]);125+ out.push_str(" aria-current=\"page\"");126+ out.push_str(&nav[pos + needle.len()..]);127+ return out;128+ }129+ nav.to_string()130+}131+49132 #[must_use]50133 pub fn page_site(51134 title: &str,@@ -56,6 +139,9 @@ pub fn page_site(56139 ) -> String {57140 let title_e = esc(title);58141 let brand = esc(site_name);142+ // One-bar header (m6 navbar, f0rge issue #18): the active link (if143+ // any) gets `aria-current="page"` before the nav is inlined. The144+ // favicon doubles as the logo mark (byte-identical to `logo.svg`).59145 let mut head = format!(60146 "<meta charset=\"utf-8\">\n\61147 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\@@ -78,13 +164,16 @@ pub fn page_site(78164 <meta property=\"og:description\" content=\"{d_e}\">\n"79165 ));80166 }167+ let nav_active = match active_href(title, body, site_name) {168+ Some(href) => apply_active(nav, href),169+ None => nav.to_string(),170+ };81171 format!(82172 "<!DOCTYPE html>\n\83173 <html lang=\"en\">\n\84174 <head>\n{head}</head>\n\85175 <body>\n\86-<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">{brand}</span></div></header>\n\87-<nav class=\"site-nav\"><div class=\"wrap\">{nav}</div></nav>\n\176+<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/favicon.svg\" alt=\"hu13 logo\" width=\"24\" height=\"24\"><span class=\"hu13-word\">hu13</span></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav><span class=\"sep\" aria-hidden=\"true\">•</span><span class=\"brand\">{brand}</span></div></header>\n\88177 <main class=\"wrap\">\n{body}\n</main>\n\89178 </body>\n\90179 </html>\n"@@ -156,4 +245,108 @@ mod tests {156245 assert!(html.contains(needle), "head carries {needle}");157246 }158247 }248+249+ #[test]250+ fn header_is_one_bar_with_logo_seps_brand_and_scroll_nav() {251+ // m6 navbar (f0rge issue #18): a single <header> holds logo +252+ // hu13 • scrollable nav • repo brand; no second top-level nav.253+ use super::{active_href, apply_active, page_site};254+ let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a>";255+ let html = page_site("demo", nav, "<p>b</p>", "demo", None);256+ assert_eq!(html.matches("<header").count(), 1, "single header bar");257+ assert!(258+ html.contains("<img class=\"site-logo\" src=\"/favicon.svg\" alt=\"hu13 logo\""),259+ "logo img with alt text"260+ );261+ assert!(262+ html.contains("<span class=\"hu13-word\">hu13</span>"),263+ "hu13 word"264+ );265+ assert!(html.matches("•").count() >= 2, "bullet separators");266+ assert!(267+ html.contains("<span class=\"brand\">demo</span>"),268+ "repo brand kept"269+ );270+ assert!(html.contains("nav-scroll"), "nav has scroll class");271+ assert!(html.contains("<nav"), "nav lives inside the header");272+ // Active mapping is title-driven; unknown bare titles mark nothing.273+ assert_eq!(active_href("demo", "<p>b</p>", "demo"), Some("/"));274+ assert_eq!(275+ active_href("commits — demo", "", "demo"),276+ Some("/commits.html")277+ );278+ assert_eq!(active_href("health", "", "hu13"), None);279+ let marked = apply_active(nav, "/commits.html");280+ assert!(281+ marked.contains("<a href=\"/commits.html\" aria-current=\"page\">"),282+ "aria-current injected, got: {marked}"283+ );284+ }285+286+ #[test]287+ fn active_tab_marks_each_section_and_file_pages_are_code() {288+ use super::{active_href, apply_active, page_site};289+ let nav = concat!(290+ "<a href=\"/\">code</a> · ",291+ "<a href=\"/commits.html\">commits</a> · ",292+ "<a href=\"/issues/index.html\">issues</a> · ",293+ "<a href=\"/pulls/index.html\">pulls</a> · ",294+ "<a href=\"/tasks/index.html\">tasks</a> · ",295+ "<a href=\"/ci/index.html\">ci</a> · ",296+ "<a href=\"/releases/index.html\">releases</a> · ",297+ "<a href=\"/wiki/index.html\">wiki</a> · ",298+ "<a href=\"/agents.html\">agents</a>",299+ );300+ for (title, body, want) in [301+ ("s", "<p>x</p>", "/"),302+ ("notes.txt — s", "<p>f</p>", "/"),303+ ("commits — s", "", "/commits.html"),304+ ("commit abc1234 — s", "", "/commits.html"),305+ ("issues — s", "", "/issues/index.html"),306+ ("pulls — s", "", "/pulls/index.html"),307+ ("tasks — s", "", "/tasks/index.html"),308+ ("ci — s", "", "/ci/index.html"),309+ ("releases — s", "", "/releases/index.html"),310+ ("v1 — releases — s", "", "/releases/index.html"),311+ ("wiki — s", "", "/wiki/index.html"),312+ ("Guide — wiki — s", "", "/wiki/index.html"),313+ ("agents — s", "", "/agents.html"),314+ ] {315+ let site = "s";316+ let title_full = if title == "s" {317+ site.to_string()318+ } else {319+ title.to_string()320+ };321+ assert_eq!(322+ active_href(&title_full, body, site),323+ Some(want),324+ "active for {title_full:?}"325+ );326+ let html = page_site(&title_full, nav, body, site, None);327+ assert_eq!(html.matches("aria-current=\"page\"").count(), 1);328+ assert!(html.contains(&format!("<a href=\"{want}\" aria-current=\"page\">")));329+ }330+ // `#N` detail pages disambiguate through the body footer links.331+ assert_eq!(332+ active_href(333+ "#3 Foo — s",334+ "<a href=\"/issues/index.html\">all issues</a>",335+ "s"336+ ),337+ Some("/issues/index.html")338+ );339+ assert_eq!(340+ active_href(341+ "#4 Bar — s",342+ "<a href=\"/pulls/index.html\">all pulls</a>",343+ "s"344+ ),345+ Some("/pulls/index.html")346+ );347+ // Bare error/health titles claim no tab; pre-marked nav is kept.348+ assert_eq!(active_href("not found", "<p>x</p>", "hu13"), None);349+ let pre = "<a href=\"/\" aria-current=\"page\">code</a>";350+ assert_eq!(apply_active(pre, "/"), pre);351+ }159352 }
static/style.css+17 −0--- a/static/style.css+++ b/static/style.css@@ -116,6 +116,23 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}116116 .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}117117 .muted{color:var(--muted)}118118 footer.site-footer .wrap{padding:1rem}119+/* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.120+ Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/121+ .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav122+ strip scrolls horizontally on narrow (no wrap, snap off) with a visible123+ focus ring; the active link gets an accent underline via aria-current. */124+.site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}125+.brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}126+.brand-lockup:visited{color:var(--fg)}127+.site-logo{display:block;width:1.5rem;height:1.5rem;flex:none}128+.hu13-word{font-weight:700;color:var(--blue)}129+.sep{flex:none;color:var(--muted)}130+.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}131+.nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}132+.site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}133+.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}134+.brand-lockup:focus-visible,.site-header .site-nav.nav-scroll a:focus-visible{outline:2px solid var(--blue);outline-offset:2px}135+.site-header .brand{flex:none}119136 @media (min-width:768px){120137 .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}121138 .site-nav .wrap{gap:.25rem 1.25rem}
tests/navbar.rs+170 −1--- /dev/null+++ b/tests/navbar.rs@@ -0,0 +1,170 @@1+//! Navbar one-bar (m6, f0rge issue #18):2+//! single `<header>` (`logo hu13 • nav • repo`), scrollable nav strip3+//! (own `nav-scroll` class: `overflow-x:auto` + nowrap, snap off, visible4+//! focus), logo img alt text, `aria-current` on the active tab.5+//!6+//! Builds a small fixture repo with the real binary and string-asserts on7+//! the emitted HTML + `static/style.css` (no rendering engine needed).8+9+use std::path::{Path, PathBuf};10+use std::process::Command;11+12+fn bin() -> &'static str {13+ env!("CARGO_BIN_EXE_hu13")14+}15+16+fn manifest() -> PathBuf {17+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))18+}19+20+fn fresh(prefix: &str) -> PathBuf {21+ let dir = std::env::temp_dir().join(format!(22+ "hu13-navbar-{prefix}-{}-{}",23+ std::process::id(),24+ std::time::SystemTime::now()25+ .duration_since(std::time::UNIX_EPOCH)26+ .map(|d| d.as_nanos())27+ .unwrap_or(0)28+ ));29+ std::fs::create_dir_all(&dir).unwrap();30+ dir31+}32+33+fn git(repo: &Path, args: &[&str]) {34+ let st = Command::new("git")35+ .arg("-C")36+ .arg(repo)37+ .args(args)38+ .status()39+ .expect("spawn git");40+ assert!(st.success(), "git {args:?} failed");41+}42+43+fn fixture_repo() -> PathBuf {44+ let dir = fresh("repo");45+ git(&dir, &["init", "-b", "main"]);46+ git(&dir, &["config", "user.name", "hu13-test"]);47+ git(&dir, &["config", "user.email", "[email protected]"]);48+ git(&dir, &["config", "commit.gpgsign", "false"]);49+ std::fs::write(dir.join("README.md"), "# navbar fixture\n\nhello\n").unwrap();50+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();51+ git(&dir, &["add", "-A"]);52+ git(&dir, &["commit", "-m", "init"]);53+ dir54+}55+56+fn build_fixture() -> PathBuf {57+ let repo = fixture_repo();58+ let out = fresh("out");59+ let st = Command::new(bin())60+ .arg("build")61+ .arg(&repo)62+ .arg("-o")63+ .arg(&out)64+ .status()65+ .expect("spawn hu13 build");66+ assert!(st.success(), "fixture build exits 0");67+ out68+}69+70+fn css() -> String {71+ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")72+}73+74+#[test]75+fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {76+ let out = build_fixture();77+ for page in ["index.html", "commits.html", "issues/index.html"] {78+ let html = std::fs::read_to_string(out.join(page)).expect(page);79+ // One header bar on every page; the nav lives inside it.80+ assert_eq!(81+ html.matches("<header").count(),82+ 1,83+ "{page} has a single <header>"84+ );85+ assert!(86+ html.contains("<nav"),87+ "{page} keeps a <nav> inside the header"88+ );89+ // Brand cluster: logo img (alt text) + hu13 • … • repo brand.90+ assert!(91+ html.contains("<img class=\"site-logo\""),92+ "{page} shows the logo img"93+ );94+ assert!(95+ html.contains("alt=\"hu13 logo\""),96+ "{page} logo carries alt text"97+ );98+ assert!(html.contains(">hu13<"), "{page} names hu13");99+ assert!(html.contains("•"), "{page} has • separators");100+ assert!(101+ html.matches("•").count() >= 2,102+ "{page} separates brand/nav/repo with •"103+ );104+ assert!(105+ html.contains("<span class=\"brand\">"),106+ "{page} keeps the repo brand"107+ );108+ // Scrollable nav strip under its own class.109+ assert!(110+ html.contains("nav-scroll"),111+ "{page} nav has the scroll class"112+ );113+ }114+}115+116+#[test]117+fn active_tab_marked_with_aria_current() {118+ let out = build_fixture();119+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");120+ assert!(121+ index.contains("<a href=\"/\" aria-current=\"page\">"),122+ "code tab active on index"123+ );124+ let commits = std::fs::read_to_string(out.join("commits.html")).expect("commits");125+ assert!(126+ commits.contains("<a href=\"/commits.html\" aria-current=\"page\">"),127+ "commits tab active on commits.html"128+ );129+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");130+ assert!(131+ issues.contains("<a href=\"/issues/index.html\" aria-current=\"page\">"),132+ "issues tab active on issues/index.html"133+ );134+ // Exactly one active tab per page.135+ for (name, html) in [136+ ("index", &index),137+ ("commits", &commits),138+ ("issues", &issues),139+ ] {140+ assert_eq!(141+ html.matches("aria-current=\"page\"").count(),142+ 1,143+ "{name} marks exactly one active tab"144+ );145+ }146+}147+148+#[test]149+fn nav_scroll_css_scrolls_without_snap_and_shows_state() {150+ let css = css();151+ // Own class (appended, earlier rules untouched).152+ assert!(css.contains(".nav-scroll"), "scroll class styled");153+ // Scrollable strip: horizontal scroll + no wrapping.154+ assert!(css.contains("overflow-x:auto"), "nav scrolls horizontally");155+ assert!(css.contains("white-space:nowrap"), "nav links never wrap");156+ // Scroll snap explicitly off (the strip is a plain scroller).157+ assert!(158+ css.contains("scroll-snap-type:none"),159+ "snap off so the strip rests anywhere"160+ );161+ // Position stays visible: focus ring + active accent underline.162+ assert!(163+ css.contains(":focus-visible"),164+ "keyboard focus stays visible"165+ );166+ assert!(167+ css.contains("aria-current"),168+ "active tab styled via aria-current"169+ );170+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.