.forge/prs/a097a750-6241-46a3-8131-1ae2dfb06b6b/patch.diff 25.7 KiB 556 lines Raw
1 From db32796c57a62ae7412d535a8d356d8c6d6c4c13 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:39:27 +01004 Subject: [PATCH] m6/logo-text: vector lettering, header logo-only5 6 ---7  src/build.rs           | 12 +++++---8  src/layout.rs          | 56 +++++++++++++++++++++++-----------9  src/logo.rs            | 43 ++++++++++++++++++++------10  static/favicon.svg     |  1 +11  static/logo.svg        | 13 +++++---12  tests/favicon.rs       | 19 +++++++++---13  tests/header_config.rs | 69 ++++++++++++++++++++++++++++++++++++------14  tests/logo.rs          | 60 ++++++++++++++++++++++++++++++++++--15  tests/navbar.rs        | 42 +++++++++++++++++--------16  9 files changed, 251 insertions(+), 64 deletions(-)17 18 diff --git a/src/build.rs b/src/build.rs19 index 5083fa7..82eaf31 10064420 --- a/src/build.rs21 +++ b/src/build.rs22 @@ -370,11 +370,15 @@ fn write_style(out: &Path) -> Result<(), String> {23      std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))24  }25  26 -/// Write `out/favicon.svg` + `out/manifest.webmanifest` from the embedded27 -/// static files (m6 favicon, f0rge issue #16). SVG-first, zero binary28 -/// blobs: the favicon is the hand-written logo mark, and the manifest29 -/// points at it (`icons` svg, `short_name` hu13, `display` minimal-ui).30 +/// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`31 +/// from the embedded static files (m6 favicon #16; logo-text #27 + #28).32 +/// SVG-first, zero binary blobs: the header lockup is `logo.svg`33 +/// (diamond-hub + hu13 as <path>, no <text>/fonts), the favicon is the34 +/// simplified mark-only variant for 16px, and the manifest points at the35 +/// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui).36  fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {37 +    let logo = include_str!("../static/logo.svg");38 +    std::fs::write(out.join("logo.svg"), logo).map_err(|e| format!("hu13: write logo.svg: {e}"))?;39      let icon = include_str!("../static/favicon.svg");40      std::fs::write(out.join("favicon.svg"), icon)41          .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;42 diff --git a/src/layout.rs b/src/layout.rs43 index af31aa2..430c397 10064444 --- a/src/layout.rs45 +++ b/src/layout.rs46 @@ -87,12 +87,15 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {47  }48  49  /// Render a full HTML page with site identity (m6 header config, f0rge50 -/// issues #21 + #24): the header brand shows `site_name`, `<title>` is51 +/// issues #21 + #24; minimal header f0rge issues #27 + #28): the header52 +/// carries zero names (logo-with-text art only + nav), `<title>` is53  /// the escaped page `title` (callers build it as `"{page} \u2014 {site}"`54  /// so the repo name is in every tab), `og:title` mirrors the page title,55  /// and `og:description` / `meta[name=description]` carry `description`56 -/// when present. `site_name` and `description` are escaped (hostile57 -/// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`].58 +/// when present. `site_name` still names the title/og surfaces (and the59 +/// feed title + code-page repo-head via `build`); it is never rendered in60 +/// the header. `description` is escaped (hostile `<script>` renders as61 +/// text). `nav`/`body` are inlined as-is like [`page`].62  ///63  /// Head standards (m6 favicon, f0rge issue #16) ride along on every page:64  /// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at65 @@ -192,10 +195,12 @@ pub fn page_site(66      description: Option<&str>,67  ) -> String {68      let title_e = esc(title);69 -    let brand = esc(site_name);70 -    // One-bar header (m6 navbar, f0rge issue #18): the active link (if71 -    // any) gets `aria-current="page"` before the nav is inlined. The72 -    // favicon doubles as the logo mark (byte-identical to `logo.svg`).73 +    // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the74 +    // active link (if any) gets `aria-current="page"` before the nav is75 +    // inlined. Logo art is `logo.svg` (diamond-hub + hu13 as <path>, no76 +    // <text>/fonts); `favicon.svg` is the simplified mark-only variant77 +    // for 16px (see `logo.rs`). Header is logo + nav only: exactly one78 +    // `•` separator, no `hu13-word` / `brand` name nodes.79      let mut head = format!(80          "<meta charset=\"utf-8\">\n\81  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\82 @@ -227,7 +232,7 @@ pub fn page_site(83  <html lang=\"en\">\n\84  <head>\n{head}</head>\n\85  <body>\n\86 -<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\87 +<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"96\" height=\"32\"></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></div></header>\n\88  <main class=\"wrap\">\n{body}\n</main>\n\89  </body>\n\90  </html>\n"91 @@ -301,25 +306,40 @@ mod tests {92      }93  94      #[test]95 -    fn header_is_one_bar_with_logo_seps_brand_and_scroll_nav() {96 -        // m6 navbar (f0rge issue #18): a single <header> holds logo +97 -        // hu13 • scrollable nav • repo brand; no second top-level nav.98 +    fn header_is_one_bar_with_logo_sep_and_scroll_nav() {99 +        // m6 navbar (#18) + logo-text (#27 + #28): a single <header>100 +        // holds logo-with-text • scrollable nav only; no hu13-word or101 +        // brand name nodes. The repo name still lives in <title>/og.102          use super::{active_href, apply_active, page_site};103          let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a>";104          let html = page_site("demo", nav, "<p>b</p>", "demo", None);105          assert_eq!(html.matches("<header").count(), 1, "single header bar");106          assert!(107 -            html.contains("<img class=\"site-logo\" src=\"/favicon.svg\" alt=\"hu13 logo\""),108 -            "logo img with alt text"109 +            html.contains("<img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\""),110 +            "logo lockup img with alt text"111          );112          assert!(113 -            html.contains("<span class=\"hu13-word\">hu13</span>"),114 -            "hu13 word"115 +            !html.contains("hu13-word"),116 +            "no hu13 word node (letters live in logo.svg paths)"117          );118 -        assert!(html.matches("•").count() >= 2, "bullet separators");119          assert!(120 -            html.contains("<span class=\"brand\">demo</span>"),121 -            "repo brand kept"122 +            !html.contains("<span class=\"brand\">"),123 +            "no repo brand node in header"124 +        );125 +        let h0 = html.find("<header").expect("header open");126 +        let h1 = html.find("</header>").expect("header close");127 +        let header = &html[h0..h1];128 +        assert!(!header.contains(">demo<"), "header carries zero names");129 +        assert_eq!(130 +            header.matches("•").count(),131 +            1,132 +            "exactly one sep (logo • nav)"133 +        );134 +        // Repo name still lives in title + og:title.135 +        assert!(html.contains("<title>demo</title>"), "title keeps name");136 +        assert!(137 +            html.contains("<meta property=\"og:title\" content=\"demo\">"),138 +            "og:title keeps name"139          );140          assert!(html.contains("nav-scroll"), "nav has scroll class");141          assert!(html.contains("<nav"), "nav lives inside the header");142 diff --git a/src/logo.rs b/src/logo.rs143 index c822220..f580b2b 100644144 --- a/src/logo.rs145 +++ b/src/logo.rs146 @@ -1,12 +1,15 @@147 -//! hu13 logo + ASCII banner (m6, f0rge issue #15).148 +//! hu13 logo + ASCII banner (m6, f0rge issues #15 + #27 + #28).149  //!150 -//! The mark lives hand-written in `static/logo.svg` (square icon, reads at151 -//! 16px) plus `static/wordmark.svg` (horizontal lockup for the navbar); the152 -//! text banner lives in `static/ascii.txt`.153 +//! The lockup lives hand-written in `static/logo.svg` (diamond-hub mark +154 +//! hu13 lettered as <path>, no <text>/fonts; reads at 24px header size)155 +//! plus `static/wordmark.svg` (legacy horizontal lockup); the simplified156 +//! 16px favicon variant lives in `static/favicon.svg` (mark only, no157 +//! letters — small sizes stay legible, full lettering stays in logo.svg);158 +//! the text banner lives in `static/ascii.txt`.159  //!160 -//! [`ASCII_ART`], [`LOGO_SVG`] and [`WORDMARK_SVG`] re-expose those static161 -//! files for later issues (navbar #16, favicon #17, wiki README #19, agents162 -//! page) with no build step.163 +//! [`ASCII_ART`], [`LOGO_SVG`], [`FAVICON_SVG`] and [`WORDMARK_SVG`]164 +//! re-expose those static files for later issues (navbar #16, favicon #17,165 +//! wiki README #19, agents page) with no build step.166  //!167  //! [`ascii_pre`] renders the banner as an escaped `<pre>` block (see168  //! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so169 @@ -22,9 +25,14 @@ use crate::layout::esc;170  pub const ASCII_ART: &str = include_str!("../static/ascii.txt");171  172  #[allow(dead_code)]173 -/// Square mark, byte-for-byte `static/logo.svg`.174 +/// Header lockup, byte-for-byte `static/logo.svg` (mark + hu13 as <path>).175  pub const LOGO_SVG: &str = include_str!("../static/logo.svg");176  177 +#[allow(dead_code)]178 +/// Simplified 16px favicon variant, byte-for-byte `static/favicon.svg`179 +/// (mark only, no letters — documented choice for small-size legibility).180 +pub const FAVICON_SVG: &str = include_str!("../static/favicon.svg");181 +182  #[allow(dead_code)]183  /// Horizontal lockup, byte-for-byte `static/wordmark.svg`.184  pub const WORDMARK_SVG: &str = include_str!("../static/wordmark.svg");185 @@ -74,9 +82,26 @@ mod tests {186  187      #[test]188      fn svg_consts_match_tiny_marks() {189 -        for (name, svg) in [("logo", LOGO_SVG), ("wordmark", WORDMARK_SVG)] {190 +        for (name, svg) in [191 +            ("logo", LOGO_SVG),192 +            ("favicon", FAVICON_SVG),193 +            ("wordmark", WORDMARK_SVG),194 +        ] {195              assert!(svg.contains("<svg"), "{name} const holds svg");196              assert!(svg.len() < 2048, "{name} const stays tiny");197          }198 +        // Logo-text (#27): lockup letters are <path>, never <text>/fonts.199 +        assert!(LOGO_SVG.contains("<path"), "logo letters are paths");200 +        assert!(!LOGO_SVG.contains("<text"), "logo has no <text>");201 +        assert!(202 +            !LOGO_SVG.to_lowercase().contains("font"),203 +            "logo has no font refs"204 +        );205 +        // Simplified favicon variant (#27): mark only, no letters.206 +        assert!(!FAVICON_SVG.contains("<text"), "favicon has no <text>");207 +        assert!(208 +            !FAVICON_SVG.contains("<path"),209 +            "favicon is mark-only (no letter paths)"210 +        );211      }212  }213 diff --git a/static/favicon.svg b/static/favicon.svg214 index 35a4426..098e978 100644215 --- a/static/favicon.svg216 +++ b/static/favicon.svg217 @@ -1,4 +1,5 @@218  <?xml version="1.0" encoding="UTF-8"?>219 +<!-- hu13 favicon: simplified mark-only variant for 16px legibility. Full lettering lives in logo.svg. -->220  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">221    <title>hu13</title>222    <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>223 diff --git a/static/logo.svg b/static/logo.svg224 index 35a4426..1e7b01c 100644225 --- a/static/logo.svg226 +++ b/static/logo.svg227 @@ -1,8 +1,13 @@228  <?xml version="1.0" encoding="UTF-8"?>229 -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">230 +<!-- hu13 lockup: diamond-hub mark + hu13 lettering as hand-drawn vectors. Reads at 24px header size. -->231 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32" width="96" height="32" role="img" aria-label="hu13 logo">232    <title>hu13</title>233 -  <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>234 -  <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>235 -  <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>236 +  <rect x="0" y="0" width="96" height="32" fill="#1a1b26"/>237 +  <rect x="0.5" y="0.5" width="95" height="31" fill="none" stroke="#292e42"/>238 +  <polygon points="16,4 28,16 16,28 4,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>239    <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>240 +  <path d="M40 8 V24 M40 15 H46 V24" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>241 +  <path d="M52 8 V21 H58 V8" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>242 +  <path d="M64 10 L66 8 V24 M62 24 H70" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>243 +  <path d="M74 8 H80 V14 H74 M74 14 H80 V24 H74" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>244  </svg>245 diff --git a/tests/favicon.rs b/tests/favicon.rs246 index 4277749..3064d9f 100644247 --- a/tests/favicon.rs248 +++ b/tests/favicon.rs249 @@ -158,10 +158,21 @@ fn favicon_emitted_svg_only_no_binary_blobs() {250      let text = String::from_utf8(raw).expect("favicon is valid UTF-8 text");251      assert!(text.contains("<svg"), "favicon is an SVG document");252      assert!(text.contains("viewBox"), "scales via viewBox");253 -    // Mirrors the logo mark (same diamond-hub geometry, same palette).254 -    let logo =255 -        std::fs::read_to_string(manifest_dir().join("static").join("logo.svg")).expect("logo.svg");256 -    assert_eq!(text, logo, "favicon adapts logo.svg exactly");257 +    // Simplified mark-only 16px variant (#27): same diamond-hub palette258 +    // as the lockup, but no letter <path>s and no <text>/fonts.259 +    let fav_src = std::fs::read_to_string(manifest_dir().join("static").join("favicon.svg"))260 +        .expect("favicon.svg");261 +    assert_eq!(text, fav_src, "emitted favicon mirrors static source");262 +    assert!(!text.contains("<text"), "favicon has no <text>");263 +    assert!(!text.contains("<path"), "favicon is mark-only");264 +    assert!(!text.to_lowercase().contains("font"), "no font refs");265 +    // Header lockup (#27 + #28) is emitted too and carries the letters.266 +    let logo_raw = std::fs::read(out.join("logo.svg")).expect("build emits logo.svg");267 +    let logo = String::from_utf8(logo_raw).expect("logo is valid UTF-8 text");268 +    assert!(logo.contains("<svg"), "logo is an SVG document");269 +    assert!(logo.contains("<path"), "logo letters are <path>");270 +    assert!(!logo.contains("<text"), "logo has no <text>");271 +    assert_ne!(text, logo, "favicon is the simplified variant, not a copy");272      // No binary icon formats anywhere in static/ or the build output.273      for dir in [manifest_dir().join("static"), out.clone()] {274          let mut stack = vec![dir.clone()];275 diff --git a/tests/header_config.rs b/tests/header_config.rs276 index 374d829..5dc9ec2 100644277 --- a/tests/header_config.rs278 +++ b/tests/header_config.rs279 @@ -1,7 +1,8 @@280 -//! Header config (m6, f0rge issues #21 + #24):281 +//! Header config (m6, f0rge issues #21 + #24 + #27 + #28):282  //! `--repo-name`/`HU13_REPO_NAME` (flag > env > dirname, never blank) +283  //! `--description`/`HU13_DESCRIPTION` (flag > env > hidden, no empty `<p>`),284 -//! threaded into header brand, `<title>`, og tags, and feed title/subtitle.285 +//! threaded into `<title>`, og tags, feed title/subtitle, and the code-page286 +//! repo-head — never into the minimal header (logo • nav only).287  //! Hostile `<script>` input renders as escaped text everywhere.288  289  use std::path::{Path, PathBuf};290 @@ -68,6 +69,12 @@ fn read(rel: &Path, join: &str) -> String {291      std::fs::read_to_string(rel.join(join)).expect(join)292  }293  294 +fn header_of(html: &str) -> &str {295 +    let start = html.find("<header").expect("header open");296 +    let end = html.find("</header>").expect("header close");297 +    &html[start..end]298 +}299 +300  fn set_env(k: &str, v: Option<&str>) {301      unsafe {302          match v {303 @@ -93,9 +100,25 @@ fn repo_name_chain_flag_beats_env_beats_dirname() {304          "env name in title"305      );306      assert!(307 -        index.contains("<span class=\"brand\">Env Name</span>"),308 -        "env name in brand"309 +        index.contains("og:title\" content=\"Env Name\""),310 +        "env name in og:title"311      );312 +    assert!(313 +        index.contains("<div class=\"repo-head\"><h1>Env Name</h1>"),314 +        "env name in code-page repo-head"315 +    );316 +    assert!(317 +        read(&out, "feed.atom").contains("<title>Env Name</title>"),318 +        "env name in feed title"319 +    );320 +    let header = header_of(&index).to_string();321 +    assert!(322 +        !header.contains("<span class=\"brand\">"),323 +        "no brand node in header"324 +    );325 +    assert!(!header.contains("hu13-word"), "no word node in header");326 +    assert_eq!(header.matches("•").count(), 1, "logo • nav only");327 +    assert!(!header.contains("Env Name"), "header carries zero names");328  329      // Flag beats env.330      set_env("HU13_REPO_NAME", Some("Env Name"));331 @@ -103,6 +126,10 @@ fn repo_name_chain_flag_beats_env_beats_dirname() {332      let index = read(&out, "index.html");333      assert!(index.contains("<title>Flag Name</title>"), "flag wins");334      assert!(!index.contains("Env Name"), "env suppressed by flag");335 +    assert!(336 +        index.contains("og:title\" content=\"Flag Name\""),337 +        "og:title follows flag"338 +    );339  340      // Neither -> dirname fallback, never blank.341      set_env("HU13_REPO_NAME", None);342 @@ -113,8 +140,12 @@ fn repo_name_chain_flag_beats_env_beats_dirname() {343          "dirname fallback"344      );345      assert!(346 -        index.contains(&format!("<span class=\"brand\">{dirname}</span>")),347 -        "brand fallback"348 +        index.contains(&format!("<div class=\"repo-head\"><h1>{dirname}</h1>")),349 +        "repo-head fallback"350 +    );351 +    assert!(352 +        !header_of(&index).contains(&dirname),353 +        "header stays nameless"354      );355      assert!(!index.contains("<h1></h1>"), "header never blank");356      set_env("HU13_REPO_NAME", None);357 @@ -178,11 +209,18 @@ fn hostile_name_and_description_escape_everywhere() {358      }359      assert!(!feed.contains("<script>alert"), "feed escapes too");360  361 -    // Escaped forms present in every surface.362 +    // Escaped forms present in every naming surface; the header stays nameless.363      let name_e = "Evil &lt;script&gt;alert(1)&lt;/script&gt; Hub";364      let desc_e = "Desc &lt;script&gt;alert(2)&lt;/script&gt; &amp; &quot;quoted&quot;";365 -    assert!(index.contains(name_e), "header/brand escapes name");366 -    assert!(index.contains(desc_e), "header escapes description");367 +    assert!(368 +        !header_of(&index).contains(name_e),369 +        "header carries zero names"370 +    );371 +    assert!(372 +        !header_of(&index).contains("<span class=\"brand\">"),373 +        "no brand node to escape into"374 +    );375 +    assert!(index.contains(desc_e), "body escapes description");376      assert!(377          index.contains(&format!("<title>{name_e}</title>")),378          "title escapes name"379 @@ -195,7 +233,18 @@ fn hostile_name_and_description_escape_everywhere() {380          index.contains(&format!("og:description\" content=\"{desc_e}\"")),381          "og:description matches"382      );383 -    assert!(commits.contains(name_e), "subpage brand escapes name");384 +    assert!(385 +        commits.contains(&format!("<title>commit")),386 +        "subpage title kept"387 +    );388 +    assert!(389 +        !header_of(&commits).contains(name_e),390 +        "subpage header stays nameless"391 +    );392 +    assert!(393 +        index.contains(&format!("<div class=\"repo-head\"><h1>{name_e}</h1>")),394 +        "code-page repo-head escapes name"395 +    );396      assert!(397          feed.contains(&format!("<title>{name_e}</title>")),398          "feed title escapes name"399 diff --git a/tests/logo.rs b/tests/logo.rs400 index 113ef4a..08884b5 100644401 --- a/tests/logo.rs402 +++ b/tests/logo.rs403 @@ -1,6 +1,7 @@404 -//! Logo suite (m6, f0rge issue #15):405 -//! `static/logo.svg` exists + valid XML + <2KB, wordmark variant,406 -//! ASCII banner limits, `<pre>` rendering invariant.407 +//! Logo suite (m6, f0rge issues #15 + #27 + #28):408 +//! `static/logo.svg` (lockup with hu13 as <path>) + `static/favicon.svg`409 +//! (simplified mark-only 16px variant) exist + valid XML + <2KB total,410 +//! wordmark variant, ASCII banner limits, `<pre>` rendering invariant.411  //!412  //! File-level suite (hu13 is a binary crate, so `src/logo.rs` unit tests413  //! cover the `ascii_pre()` helper itself; here we assert on disk).414 @@ -138,6 +139,59 @@ fn logo_svg_exists_valid_tiny() {415          assert!(svg.contains(color), "Tokyo Night palette keeps {color}");416      }417      assert_sharp_no_gradient("logo.svg", &svg);418 +    // Logo-text (#27): hu13 lettering is hand-written <path>, never419 +    // <text>/fonts, so the header lockup has no font dependency.420 +    assert!(svg.contains("<path"), "logo letters are <path>");421 +    assert!(!svg.contains("<text"), "logo has no <text>");422 +    assert!(423 +        !svg.to_lowercase().contains("font"),424 +        "logo has no font refs"425 +    );426 +    assert!(427 +        svg.matches("<path").count() >= 2,428 +        "logo carries mark + letter paths"429 +    );430 +}431 +432 +#[test]433 +fn favicon_variant_is_simplified_mark_only() {434 +    // Logo-text (#27): the 16px favicon is the simplified mark-only435 +    // variant (no letters) so it stays legible; full lettering lives in436 +    // logo.svg. Both stay valid XML, sharp, tiny, with <2KB combined.437 +    let fav_path = manifest().join("static").join("favicon.svg");438 +    let fav_len = std::fs::metadata(&fav_path)439 +        .expect("static/favicon.svg exists")440 +        .len();441 +    assert!(442 +        fav_len < 2048,443 +        "favicon.svg is {fav_len} bytes, budget is 2KB"444 +    );445 +    let fav = read_static("favicon.svg");446 +    check_well_formed(&fav);447 +    assert!(fav.contains("<svg"), "favicon has an <svg> root");448 +    assert!(fav.contains("viewBox"), "favicon scales via viewBox");449 +    assert!(!fav.contains("<text"), "favicon has no <text>");450 +    assert!(451 +        !fav.to_lowercase().contains("font"),452 +        "favicon has no font refs"453 +    );454 +    assert!(455 +        !fav.contains("<path"),456 +        "favicon is mark-only (no letter <path>s)"457 +    );458 +    assert_sharp_no_gradient("favicon.svg", &fav);459 +    for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {460 +        assert!(fav.contains(color), "favicon keeps {color}");461 +    }462 +    let logo_len = std::fs::metadata(manifest().join("static").join("logo.svg"))463 +        .expect("static/logo.svg exists")464 +        .len();465 +    assert!(466 +        logo_len + fav_len < 2048,467 +        "logo ({logo_len}B) + favicon ({fav_len}B) stay under 2KB total"468 +    );469 +    let logo = read_static("logo.svg");470 +    assert_ne!(logo, fav, "favicon is a simplified variant, not a copy");471  }472  473  #[test]474 diff --git a/tests/navbar.rs b/tests/navbar.rs475 index 701a9d9..bba644c 100644476 --- a/tests/navbar.rs477 +++ b/tests/navbar.rs478 @@ -1,5 +1,5 @@479 -//! Navbar one-bar (m6, f0rge issue #18):480 -//! single `<header>` (`logo hu13 • nav • repo`), scrollable nav strip481 +//! Navbar one-bar (m6, f0rge issues #18 + #27 + #28):482 +//! single `<header>` (logo-with-text • nav only), scrollable nav strip483  //! (own `nav-scroll` class: `overflow-x:auto` + nowrap, snap off, visible484  //! focus), logo img alt text, `aria-current` on the active tab.485  //!486 @@ -72,7 +72,7 @@ fn css() -> String {487  }488  489  #[test]490 -fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {491 +fn one_header_bar_with_logo_sep_and_scroll_nav() {492      let out = build_fixture();493      for page in ["index.html", "commits.html", "issues/index.html"] {494          let html = std::fs::read_to_string(out.join(page)).expect(page);495 @@ -86,24 +86,28 @@ fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {496              html.contains("<nav"),497              "{page} keeps a <nav> inside the header"498          );499 -        // Brand cluster: logo img (alt text) + hu13 • … • repo brand.500 +        // Minimal lockup (#27 + #28): logo-with-text art • nav only.501          assert!(502 -            html.contains("<img class=\"site-logo\""),503 -            "{page} shows the logo img"504 +            html.contains("<img class=\"site-logo\" src=\"/logo.svg\""),505 +            "{page} shows the logo lockup img"506          );507          assert!(508              html.contains("alt=\"hu13 logo\""),509              "{page} logo carries alt text"510          );511 -        assert!(html.contains(">hu13<"), "{page} names hu13");512 -        assert!(html.contains("•"), "{page} has • separators");513          assert!(514 -            html.matches("•").count() >= 2,515 -            "{page} separates brand/nav/repo with •"516 +            !html.contains("hu13-word"),517 +            "{page} drops the hu13 word node (letters live in logo.svg)"518          );519          assert!(520 -            html.contains("<span class=\"brand\">"),521 -            "{page} keeps the repo brand"522 +            !html.contains("<span class=\"brand\">"),523 +            "{page} drops the repo brand node"524 +        );525 +        let header = html.split("</header>").next().expect("header close");526 +        assert_eq!(527 +            header.matches("•").count(),528 +            1,529 +            "{page} header is logo • nav (exactly one sep)"530          );531          // Scrollable nav strip under its own class.532          assert!(533 @@ -111,6 +115,20 @@ fn one_header_bar_with_logo_seps_brand_and_scroll_nav() {534              "{page} nav has the scroll class"535          );536      }537 +    // Repo name still lives outside the header (#28): title, og:title,538 +    // code-page repo-head, and feed title.539 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");540 +    assert!(index.contains("<title>"), "title kept");541 +    assert!(542 +        index.contains("<meta property=\"og:title\""),543 +        "og:title kept"544 +    );545 +    assert!(546 +        index.contains("<div class=\"repo-head\"><h1>"),547 +        "code-page repo-head title kept"548 +    );549 +    let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed");550 +    assert!(feed.contains("<title>"), "feed title kept");551  }552  553  #[test]554 -- 555 2.55.0556