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