#20 m6/navbar

mergedpassopened
details
branches
master ← m6/navbar
updated
id
c25d4b91-0029-468b-900a-5e140c49f1e7

Patch

src/layout.rs+195 −2
diff --git a/src/layout.rs b/src/layout.rsindex 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 {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
diff --git a/static/style.css b/static/style.cssindex 8a0e3b2..2dbc66c 100644--- 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
diff --git a/tests/navbar.rs b/tests/navbar.rsnew file mode 100644index 0000000..701a9d9--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls