.forge/prs/c25d4b91-0029-468b-900a-5e140c49f1e7/patch.diff 17.6 KiB 452 lines Raw
1 From e43d297dcdde741492637659831f0382d17aed64 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:00:44 +01004 Subject: [PATCH] m6/navbar: one header bar, logo+bullets+scroll nav (1455  tests)6 7 ---8  src/layout.rs    | 197 ++++++++++++++++++++++++++++++++++++++++++++++-9  static/style.css |  17 ++++10  tests/navbar.rs  | 170 ++++++++++++++++++++++++++++++++++++++++11  3 files changed, 382 insertions(+), 2 deletions(-)12  create mode 100644 tests/navbar.rs13 14 diff --git a/src/layout.rs b/src/layout.rs15 index 691a85f..fbecd44 10064416 --- a/src/layout.rs17 +++ b/src/layout.rs18 @@ -46,6 +46,89 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {19  /// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching20  /// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`21  /// link..22 +/// Which nav `href` is active for a page (m6 navbar, f0rge issue #18).23 +///24 +/// `page_site` knows only `title`/`body`/`site_name`, so the mapping stays25 +/// title-driven: exact section titles (`commits`, `issues`, …) map to their26 +/// index hrefs, `commit <sha>` details map to commits, `X — releases` /27 +/// `X — wiki` details map to their indexes, `Suggest an issue`/`Suggest a28 +/// change` map to issues/pulls, and `#N …` detail pages disambiguate via29 +/// the body (`all issues` vs `all pulls`). File/dir pages (`{path} —30 +/// {site}` with no section prefix) map to `/` (code); bare titles with no31 +/// site suffix (health, 404s, inbox acks) map to nothing so no tab claims32 +/// them. Returns the exact `href` to mark, or `None` for no active tab.33 +#[must_use]34 +pub fn active_href(title: &str, body: &str, site_name: &str) -> Option<&'static str> {35 +    if title == site_name {36 +        return Some("/");37 +    }38 +    let suffix = format!(" — {site_name}");39 +    let base = title.strip_suffix(&suffix).unwrap_or(title);40 +    if base == "commits" || base == "commit" || base.starts_with("commit ") {41 +        return Some("/commits.html");42 +    }43 +    if base == "issues" {44 +        return Some("/issues/index.html");45 +    }46 +    if base == "pulls" {47 +        return Some("/pulls/index.html");48 +    }49 +    if base == "tasks" {50 +        return Some("/tasks/index.html");51 +    }52 +    if base == "ci" {53 +        return Some("/ci/index.html");54 +    }55 +    if base == "releases" || base.ends_with(" — releases") {56 +        return Some("/releases/index.html");57 +    }58 +    if base == "wiki" || base.ends_with(" — wiki") {59 +        return Some("/wiki/index.html");60 +    }61 +    if base == "agents" {62 +        return Some("/agents.html");63 +    }64 +    if base == "Suggest an issue" {65 +        return Some("/issues/index.html");66 +    }67 +    if base == "Suggest a change" {68 +        return Some("/pulls/index.html");69 +    }70 +    if base.starts_with('#') {71 +        if body.contains("all pulls") {72 +            return Some("/pulls/index.html");73 +        }74 +        if body.contains("all issues") {75 +            return Some("/issues/index.html");76 +        }77 +        return None;78 +    }79 +    // File/dir pages carry the site suffix but matched no section above.80 +    if title.ends_with(&suffix) {81 +        return Some("/");82 +    }83 +    None84 +}85 +86 +/// Inject `aria-current="page"` into the first nav link matching `href`87 +/// (m6 navbar, f0rge issue #18). Leaves nav untouched when it already88 +/// carries an `aria-current` or lacks the href (unit-test stubs, 404s).89 +#[must_use]90 +pub fn apply_active(nav: &str, href: &str) -> String {91 +    if nav.contains("aria-current") {92 +        return nav.to_string();93 +    }94 +    let needle = format!("<a href=\"{href}\"");95 +    if let Some(pos) = nav.find(&needle) {96 +        let mut out = String::with_capacity(nav.len() + 22);97 +        out.push_str(&nav[..pos + needle.len()]);98 +        out.push_str(" aria-current=\"page\"");99 +        out.push_str(&nav[pos + needle.len()..]);100 +        return out;101 +    }102 +    nav.to_string()103 +}104 +105  #[must_use]106  pub fn page_site(107      title: &str,108 @@ -56,6 +139,9 @@ pub fn page_site(109  ) -> String {110      let title_e = esc(title);111      let brand = esc(site_name);112 +    // One-bar header (m6 navbar, f0rge issue #18): the active link (if113 +    // any) gets `aria-current="page"` before the nav is inlined. The114 +    // favicon doubles as the logo mark (byte-identical to `logo.svg`).115      let mut head = format!(116          "<meta charset=\"utf-8\">\n\117  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\118 @@ -78,13 +164,16 @@ pub fn page_site(119  <meta property=\"og:description\" content=\"{d_e}\">\n"120          ));121      }122 +    let nav_active = match active_href(title, body, site_name) {123 +        Some(href) => apply_active(nav, href),124 +        None => nav.to_string(),125 +    };126      format!(127          "<!DOCTYPE html>\n\128  <html lang=\"en\">\n\129  <head>\n{head}</head>\n\130  <body>\n\131 -<header class=\"site-header\"><div class=\"wrap\"><span class=\"brand\">{brand}</span></div></header>\n\132 -<nav class=\"site-nav\"><div class=\"wrap\">{nav}</div></nav>\n\133 +<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\134  <main class=\"wrap\">\n{body}\n</main>\n\135  </body>\n\136  </html>\n"137 @@ -156,4 +245,108 @@ mod tests {138              assert!(html.contains(needle), "head carries {needle}");139          }140      }141 +142 +    #[test]143 +    fn header_is_one_bar_with_logo_seps_brand_and_scroll_nav() {144 +        // m6 navbar (f0rge issue #18): a single <header> holds logo +145 +        // hu13 • scrollable nav • repo brand; no second top-level nav.146 +        use super::{active_href, apply_active, page_site};147 +        let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a>";148 +        let html = page_site("demo", nav, "<p>b</p>", "demo", None);149 +        assert_eq!(html.matches("<header").count(), 1, "single header bar");150 +        assert!(151 +            html.contains("<img class=\"site-logo\" src=\"/favicon.svg\" alt=\"hu13 logo\""),152 +            "logo img with alt text"153 +        );154 +        assert!(155 +            html.contains("<span class=\"hu13-word\">hu13</span>"),156 +            "hu13 word"157 +        );158 +        assert!(html.matches("•").count() >= 2, "bullet separators");159 +        assert!(160 +            html.contains("<span class=\"brand\">demo</span>"),161 +            "repo brand kept"162 +        );163 +        assert!(html.contains("nav-scroll"), "nav has scroll class");164 +        assert!(html.contains("<nav"), "nav lives inside the header");165 +        // Active mapping is title-driven; unknown bare titles mark nothing.166 +        assert_eq!(active_href("demo", "<p>b</p>", "demo"), Some("/"));167 +        assert_eq!(168 +            active_href("commits — demo", "", "demo"),169 +            Some("/commits.html")170 +        );171 +        assert_eq!(active_href("health", "", "hu13"), None);172 +        let marked = apply_active(nav, "/commits.html");173 +        assert!(174 +            marked.contains("<a href=\"/commits.html\" aria-current=\"page\">"),175 +            "aria-current injected, got: {marked}"176 +        );177 +    }178 +179 +    #[test]180 +    fn active_tab_marks_each_section_and_file_pages_are_code() {181 +        use super::{active_href, apply_active, page_site};182 +        let nav = concat!(183 +            "<a href=\"/\">code</a> · ",184 +            "<a href=\"/commits.html\">commits</a> · ",185 +            "<a href=\"/issues/index.html\">issues</a> · ",186 +            "<a href=\"/pulls/index.html\">pulls</a> · ",187 +            "<a href=\"/tasks/index.html\">tasks</a> · ",188 +            "<a href=\"/ci/index.html\">ci</a> · ",189 +            "<a href=\"/releases/index.html\">releases</a> · ",190 +            "<a href=\"/wiki/index.html\">wiki</a> · ",191 +            "<a href=\"/agents.html\">agents</a>",192 +        );193 +        for (title, body, want) in [194 +            ("s", "<p>x</p>", "/"),195 +            ("notes.txt — s", "<p>f</p>", "/"),196 +            ("commits — s", "", "/commits.html"),197 +            ("commit abc1234 — s", "", "/commits.html"),198 +            ("issues — s", "", "/issues/index.html"),199 +            ("pulls — s", "", "/pulls/index.html"),200 +            ("tasks — s", "", "/tasks/index.html"),201 +            ("ci — s", "", "/ci/index.html"),202 +            ("releases — s", "", "/releases/index.html"),203 +            ("v1 — releases — s", "", "/releases/index.html"),204 +            ("wiki — s", "", "/wiki/index.html"),205 +            ("Guide — wiki — s", "", "/wiki/index.html"),206 +            ("agents — s", "", "/agents.html"),207 +        ] {208 +            let site = "s";209 +            let title_full = if title == "s" {210 +                site.to_string()211 +            } else {212 +                title.to_string()213 +            };214 +            assert_eq!(215 +                active_href(&title_full, body, site),216 +                Some(want),217 +                "active for {title_full:?}"218 +            );219 +            let html = page_site(&title_full, nav, body, site, None);220 +            assert_eq!(html.matches("aria-current=\"page\"").count(), 1);221 +            assert!(html.contains(&format!("<a href=\"{want}\" aria-current=\"page\">")));222 +        }223 +        // `#N` detail pages disambiguate through the body footer links.224 +        assert_eq!(225 +            active_href(226 +                "#3 Foo — s",227 +                "<a href=\"/issues/index.html\">all issues</a>",228 +                "s"229 +            ),230 +            Some("/issues/index.html")231 +        );232 +        assert_eq!(233 +            active_href(234 +                "#4 Bar — s",235 +                "<a href=\"/pulls/index.html\">all pulls</a>",236 +                "s"237 +            ),238 +            Some("/pulls/index.html")239 +        );240 +        // Bare error/health titles claim no tab; pre-marked nav is kept.241 +        assert_eq!(active_href("not found", "<p>x</p>", "hu13"), None);242 +        let pre = "<a href=\"/\" aria-current=\"page\">code</a>";243 +        assert_eq!(apply_active(pre, "/"), pre);244 +    }245  }246 diff --git a/static/style.css b/static/style.css247 index 8a0e3b2..2dbc66c 100644248 --- a/static/style.css249 +++ b/static/style.css250 @@ -116,6 +116,23 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}251  .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}252  .muted{color:var(--muted)}253  footer.site-footer .wrap{padding:1rem}254 +/* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.255 +   Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/256 +   .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav257 +   strip scrolls horizontally on narrow (no wrap, snap off) with a visible258 +   focus ring; the active link gets an accent underline via aria-current. */259 +.site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}260 +.brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}261 +.brand-lockup:visited{color:var(--fg)}262 +.site-logo{display:block;width:1.5rem;height:1.5rem;flex:none}263 +.hu13-word{font-weight:700;color:var(--blue)}264 +.sep{flex:none;color:var(--muted)}265 +.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}266 +.nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}267 +.site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}268 +.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}269 +.brand-lockup:focus-visible,.site-header .site-nav.nav-scroll a:focus-visible{outline:2px solid var(--blue);outline-offset:2px}270 +.site-header .brand{flex:none}271  @media (min-width:768px){272    .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}273    .site-nav .wrap{gap:.25rem 1.25rem}274 diff --git a/tests/navbar.rs b/tests/navbar.rs275 new file mode 100644276 index 0000000..701a9d9277 --- /dev/null278 +++ b/tests/navbar.rs279 @@ -0,0 +1,170 @@280 +//! Navbar one-bar (m6, f0rge issue #18):281 +//! single `<header>` (`logo hu13 • nav • repo`), scrollable nav strip282 +//! (own `nav-scroll` class: `overflow-x:auto` + nowrap, snap off, visible283 +//! focus), logo img alt text, `aria-current` on the active tab.284 +//!285 +//! Builds a small fixture repo with the real binary and string-asserts on286 +//! the emitted HTML + `static/style.css` (no rendering engine needed).287 +288 +use std::path::{Path, PathBuf};289 +use std::process::Command;290 +291 +fn bin() -> &'static str {292 +    env!("CARGO_BIN_EXE_hu13")293 +}294 +295 +fn manifest() -> PathBuf {296 +    PathBuf::from(env!("CARGO_MANIFEST_DIR"))297 +}298 +299 +fn fresh(prefix: &str) -> PathBuf {300 +    let dir = std::env::temp_dir().join(format!(301 +        "hu13-navbar-{prefix}-{}-{}",302 +        std::process::id(),303 +        std::time::SystemTime::now()304 +            .duration_since(std::time::UNIX_EPOCH)305 +            .map(|d| d.as_nanos())306 +            .unwrap_or(0)307 +    ));308 +    std::fs::create_dir_all(&dir).unwrap();309 +    dir310 +}311 +312 +fn git(repo: &Path, args: &[&str]) {313 +    let st = Command::new("git")314 +        .arg("-C")315 +        .arg(repo)316 +        .args(args)317 +        .status()318 +        .expect("spawn git");319 +    assert!(st.success(), "git {args:?} failed");320 +}321 +322 +fn fixture_repo() -> PathBuf {323 +    let dir = fresh("repo");324 +    git(&dir, &["init", "-b", "main"]);325 +    git(&dir, &["config", "user.name", "hu13-test"]);326 +    git(&dir, &["config", "user.email", "[email protected]"]);327 +    git(&dir, &["config", "commit.gpgsign", "false"]);328 +    std::fs::write(dir.join("README.md"), "# navbar fixture\n\nhello\n").unwrap();329 +    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();330 +    git(&dir, &["add", "-A"]);331 +    git(&dir, &["commit", "-m", "init"]);332 +    dir333 +}334 +335 +fn build_fixture() -> PathBuf {336 +    let repo = fixture_repo();337 +    let out = fresh("out");338 +    let st = Command::new(bin())339 +        .arg("build")340 +        .arg(&repo)341 +        .arg("-o")342 +        .arg(&out)343 +        .status()344 +        .expect("spawn hu13 build");345 +    assert!(st.success(), "fixture build exits 0");346 +    out347 +}348 +349 +fn css() -> String {350 +    std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")351 +}352 +353 +#[test]354 +fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {355 +    let out = build_fixture();356 +    for page in ["index.html", "commits.html", "issues/index.html"] {357 +        let html = std::fs::read_to_string(out.join(page)).expect(page);358 +        // One header bar on every page; the nav lives inside it.359 +        assert_eq!(360 +            html.matches("<header").count(),361 +            1,362 +            "{page} has a single <header>"363 +        );364 +        assert!(365 +            html.contains("<nav"),366 +            "{page} keeps a <nav> inside the header"367 +        );368 +        // Brand cluster: logo img (alt text) + hu13 • … • repo brand.369 +        assert!(370 +            html.contains("<img class=\"site-logo\""),371 +            "{page} shows the logo img"372 +        );373 +        assert!(374 +            html.contains("alt=\"hu13 logo\""),375 +            "{page} logo carries alt text"376 +        );377 +        assert!(html.contains(">hu13<"), "{page} names hu13");378 +        assert!(html.contains("•"), "{page} has • separators");379 +        assert!(380 +            html.matches("•").count() >= 2,381 +            "{page} separates brand/nav/repo with •"382 +        );383 +        assert!(384 +            html.contains("<span class=\"brand\">"),385 +            "{page} keeps the repo brand"386 +        );387 +        // Scrollable nav strip under its own class.388 +        assert!(389 +            html.contains("nav-scroll"),390 +            "{page} nav has the scroll class"391 +        );392 +    }393 +}394 +395 +#[test]396 +fn active_tab_marked_with_aria_current() {397 +    let out = build_fixture();398 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");399 +    assert!(400 +        index.contains("<a href=\"/\" aria-current=\"page\">"),401 +        "code tab active on index"402 +    );403 +    let commits = std::fs::read_to_string(out.join("commits.html")).expect("commits");404 +    assert!(405 +        commits.contains("<a href=\"/commits.html\" aria-current=\"page\">"),406 +        "commits tab active on commits.html"407 +    );408 +    let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");409 +    assert!(410 +        issues.contains("<a href=\"/issues/index.html\" aria-current=\"page\">"),411 +        "issues tab active on issues/index.html"412 +    );413 +    // Exactly one active tab per page.414 +    for (name, html) in [415 +        ("index", &index),416 +        ("commits", &commits),417 +        ("issues", &issues),418 +    ] {419 +        assert_eq!(420 +            html.matches("aria-current=\"page\"").count(),421 +            1,422 +            "{name} marks exactly one active tab"423 +        );424 +    }425 +}426 +427 +#[test]428 +fn nav_scroll_css_scrolls_without_snap_and_shows_state() {429 +    let css = css();430 +    // Own class (appended, earlier rules untouched).431 +    assert!(css.contains(".nav-scroll"), "scroll class styled");432 +    // Scrollable strip: horizontal scroll + no wrapping.433 +    assert!(css.contains("overflow-x:auto"), "nav scrolls horizontally");434 +    assert!(css.contains("white-space:nowrap"), "nav links never wrap");435 +    // Scroll snap explicitly off (the strip is a plain scroller).436 +    assert!(437 +        css.contains("scroll-snap-type:none"),438 +        "snap off so the strip rests anywhere"439 +    );440 +    // Position stays visible: focus ring + active accent underline.441 +    assert!(442 +        css.contains(":focus-visible"),443 +        "keyboard focus stays visible"444 +    );445 +    assert!(446 +        css.contains("aria-current"),447 +        "active tab styled via aria-current"448 +    );449 +}450 -- 451 2.55.0452