From db32796c57a62ae7412d535a8d356d8c6d6c4c13 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 07:39:27 +0100 Subject: [PATCH] m6/logo-text: vector lettering, header logo-only --- src/build.rs | 12 +++++--- src/layout.rs | 56 +++++++++++++++++++++++----------- src/logo.rs | 43 ++++++++++++++++++++------ static/favicon.svg | 1 + static/logo.svg | 13 +++++--- tests/favicon.rs | 19 +++++++++--- tests/header_config.rs | 69 ++++++++++++++++++++++++++++++++++++------ tests/logo.rs | 60 ++++++++++++++++++++++++++++++++++-- tests/navbar.rs | 42 +++++++++++++++++-------- 9 files changed, 251 insertions(+), 64 deletions(-) diff --git a/src/build.rs b/src/build.rs index 5083fa7..82eaf31 100644 --- a/src/build.rs +++ b/src/build.rs @@ -370,11 +370,15 @@ fn write_style(out: &Path) -> Result<(), String> { std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}")) } -/// Write `out/favicon.svg` + `out/manifest.webmanifest` from the embedded -/// static files (m6 favicon, f0rge issue #16). SVG-first, zero binary -/// blobs: the favicon is the hand-written logo mark, and the manifest -/// points at it (`icons` svg, `short_name` hu13, `display` minimal-ui). +/// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest` +/// from the embedded static files (m6 favicon #16; logo-text #27 + #28). +/// SVG-first, zero binary blobs: the header lockup is `logo.svg` +/// (diamond-hub + hu13 as , no /fonts), the favicon is the +/// simplified mark-only variant for 16px, and the manifest points at the +/// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui). fn write_favicon_and_manifest(out: &Path) -> Result<(), String> { + let logo = include_str!("../static/logo.svg"); + std::fs::write(out.join("logo.svg"), logo).map_err(|e| format!("hu13: write logo.svg: {e}"))?; let icon = include_str!("../static/favicon.svg"); std::fs::write(out.join("favicon.svg"), icon) .map_err(|e| format!("hu13: write favicon.svg: {e}"))?; diff --git a/src/layout.rs b/src/layout.rs index af31aa2..430c397 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -87,12 +87,15 @@ pub fn page(title: &str, nav: &str, body: &str) -> String { } /// Render a full HTML page with site identity (m6 header config, f0rge -/// issues #21 + #24): the header brand shows `site_name`, `` is +/// issues #21 + #24; minimal header f0rge issues #27 + #28): the header +/// carries zero names (logo-with-text art only + nav), `<title>` is /// the escaped page `title` (callers build it as `"{page} \u2014 {site}"` /// so the repo name is in every tab), `og:title` mirrors the page title, /// and `og:description` / `meta[name=description]` carry `description` -/// when present. `site_name` and `description` are escaped (hostile -/// `<script>` renders as text). `nav`/`body` are inlined as-is like [`page`]. +/// when present. `site_name` still names the title/og surfaces (and the +/// feed title + code-page repo-head via `build`); it is never rendered in +/// the header. `description` is escaped (hostile `<script>` renders as +/// text). `nav`/`body` are inlined as-is like [`page`]. /// /// Head standards (m6 favicon, f0rge issue #16) ride along on every page: /// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at @@ -192,10 +195,12 @@ pub fn page_site( description: Option<&str>, ) -> String { let title_e = esc(title); - let brand = esc(site_name); - // One-bar header (m6 navbar, f0rge issue #18): the active link (if - // any) gets `aria-current="page"` before the nav is inlined. The - // favicon doubles as the logo mark (byte-identical to `logo.svg`). + // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the + // active link (if any) gets `aria-current="page"` before the nav is + // inlined. Logo art is `logo.svg` (diamond-hub + hu13 as <path>, no + // <text>/fonts); `favicon.svg` is the simplified mark-only variant + // for 16px (see `logo.rs`). Header is logo + nav only: exactly one + // `•` separator, no `hu13-word` / `brand` name nodes. let mut head = format!( "<meta charset=\"utf-8\">\n\ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ @@ -227,7 +232,7 @@ pub fn page_site( <html lang=\"en\">\n\ <head>\n{head}</head>\n\ <body>\n\ -<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\ +<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\ <main class=\"wrap\">\n{body}\n</main>\n\ </body>\n\ </html>\n" @@ -301,25 +306,40 @@ mod tests { } #[test] - fn header_is_one_bar_with_logo_seps_brand_and_scroll_nav() { - // m6 navbar (f0rge issue #18): a single <header> holds logo + - // hu13 • scrollable nav • repo brand; no second top-level nav. + fn header_is_one_bar_with_logo_sep_and_scroll_nav() { + // m6 navbar (#18) + logo-text (#27 + #28): a single <header> + // holds logo-with-text • scrollable nav only; no hu13-word or + // brand name nodes. The repo name still lives in <title>/og. use super::{active_href, apply_active, page_site}; let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a>"; let html = page_site("demo", nav, "<p>b</p>", "demo", None); assert_eq!(html.matches("<header").count(), 1, "single header bar"); assert!( - html.contains("<img class=\"site-logo\" src=\"/favicon.svg\" alt=\"hu13 logo\""), - "logo img with alt text" + html.contains("<img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\""), + "logo lockup img with alt text" ); assert!( - html.contains("<span class=\"hu13-word\">hu13</span>"), - "hu13 word" + !html.contains("hu13-word"), + "no hu13 word node (letters live in logo.svg paths)" ); - assert!(html.matches("•").count() >= 2, "bullet separators"); assert!( - html.contains("<span class=\"brand\">demo</span>"), - "repo brand kept" + !html.contains("<span class=\"brand\">"), + "no repo brand node in header" + ); + let h0 = html.find("<header").expect("header open"); + let h1 = html.find("</header>").expect("header close"); + let header = &html[h0..h1]; + assert!(!header.contains(">demo<"), "header carries zero names"); + assert_eq!( + header.matches("•").count(), + 1, + "exactly one sep (logo • nav)" + ); + // Repo name still lives in title + og:title. + assert!(html.contains("<title>demo"), "title keeps name"); + assert!( + html.contains(""), + "og:title keeps name" ); assert!(html.contains("nav-scroll"), "nav has scroll class"); assert!(html.contains(", no /fonts; reads at 24px header size) +//! plus `static/wordmark.svg` (legacy horizontal lockup); the simplified +//! 16px favicon variant lives in `static/favicon.svg` (mark only, no +//! letters — small sizes stay legible, full lettering stays in logo.svg); +//! the text banner lives in `static/ascii.txt`. //! -//! [`ASCII_ART`], [`LOGO_SVG`] and [`WORDMARK_SVG`] re-expose those static -//! files for later issues (navbar #16, favicon #17, wiki README #19, agents -//! page) with no build step. +//! [`ASCII_ART`], [`LOGO_SVG`], [`FAVICON_SVG`] and [`WORDMARK_SVG`] +//! re-expose those static files for later issues (navbar #16, favicon #17, +//! wiki README #19, agents page) with no build step. //! //! [`ascii_pre`] renders the banner as an escaped `
` block (see
 //! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so
@@ -22,9 +25,14 @@ use crate::layout::esc;
 pub const ASCII_ART: &str = include_str!("../static/ascii.txt");
 
 #[allow(dead_code)]
-/// Square mark, byte-for-byte `static/logo.svg`.
+/// Header lockup, byte-for-byte `static/logo.svg` (mark + hu13 as ).
 pub const LOGO_SVG: &str = include_str!("../static/logo.svg");
 
+#[allow(dead_code)]
+/// Simplified 16px favicon variant, byte-for-byte `static/favicon.svg`
+/// (mark only, no letters — documented choice for small-size legibility).
+pub const FAVICON_SVG: &str = include_str!("../static/favicon.svg");
+
 #[allow(dead_code)]
 /// Horizontal lockup, byte-for-byte `static/wordmark.svg`.
 pub const WORDMARK_SVG: &str = include_str!("../static/wordmark.svg");
@@ -74,9 +82,26 @@ mod tests {
 
     #[test]
     fn svg_consts_match_tiny_marks() {
-        for (name, svg) in [("logo", LOGO_SVG), ("wordmark", WORDMARK_SVG)] {
+        for (name, svg) in [
+            ("logo", LOGO_SVG),
+            ("favicon", FAVICON_SVG),
+            ("wordmark", WORDMARK_SVG),
+        ] {
             assert!(svg.contains(", never /fonts.
+        assert!(LOGO_SVG.contains("");
+        assert!(
+            !LOGO_SVG.to_lowercase().contains("font"),
+            "logo has no font refs"
+        );
+        // Simplified favicon variant (#27): mark only, no letters.
+        assert!(!FAVICON_SVG.contains("");
+        assert!(
+            !FAVICON_SVG.contains("
+
 
   hu13
   
diff --git a/static/logo.svg b/static/logo.svg
index 35a4426..1e7b01c 100644
--- a/static/logo.svg
+++ b/static/logo.svg
@@ -1,8 +1,13 @@
 
-
+
+
   hu13
-  
-  
-  
+  
+  
+  
   
+  
+  
+  
+  
 
diff --git a/tests/favicon.rs b/tests/favicon.rs
index 4277749..3064d9f 100644
--- a/tests/favicon.rs
+++ b/tests/favicon.rs
@@ -158,10 +158,21 @@ fn favicon_emitted_svg_only_no_binary_blobs() {
     let text = String::from_utf8(raw).expect("favicon is valid UTF-8 text");
     assert!(text.contains("s and no /fonts.
+    let fav_src = std::fs::read_to_string(manifest_dir().join("static").join("favicon.svg"))
+        .expect("favicon.svg");
+    assert_eq!(text, fav_src, "emitted favicon mirrors static source");
+    assert!(!text.contains("");
+    assert!(!text.contains("");
+    assert!(!logo.contains("");
+    assert_ne!(text, logo, "favicon is the simplified variant, not a copy");
     // No binary icon formats anywhere in static/ or the build output.
     for dir in [manifest_dir().join("static"), out.clone()] {
         let mut stack = vec![dir.clone()];
diff --git a/tests/header_config.rs b/tests/header_config.rs
index 374d829..5dc9ec2 100644
--- a/tests/header_config.rs
+++ b/tests/header_config.rs
@@ -1,7 +1,8 @@
-//! Header config (m6, f0rge issues #21 + #24):
+//! Header config (m6, f0rge issues #21 + #24 + #27 + #28):
 //! `--repo-name`/`HU13_REPO_NAME` (flag > env > dirname, never blank) +
 //! `--description`/`HU13_DESCRIPTION` (flag > env > hidden, no empty `

`), -//! threaded into header brand, ``, og tags, and feed title/subtitle. +//! threaded into `<title>`, og tags, feed title/subtitle, and the code-page +//! repo-head — never into the minimal header (logo • nav only). //! Hostile `<script>` input renders as escaped text everywhere. use std::path::{Path, PathBuf}; @@ -68,6 +69,12 @@ fn read(rel: &Path, join: &str) -> String { std::fs::read_to_string(rel.join(join)).expect(join) } +fn header_of(html: &str) -> &str { + let start = html.find("<header").expect("header open"); + let end = html.find("</header>").expect("header close"); + &html[start..end] +} + fn set_env(k: &str, v: Option<&str>) { unsafe { match v { @@ -93,9 +100,25 @@ fn repo_name_chain_flag_beats_env_beats_dirname() { "env name in title" ); assert!( - index.contains("<span class=\"brand\">Env Name</span>"), - "env name in brand" + index.contains("og:title\" content=\"Env Name\""), + "env name in og:title" ); + assert!( + index.contains("<div class=\"repo-head\"><h1>Env Name</h1>"), + "env name in code-page repo-head" + ); + assert!( + read(&out, "feed.atom").contains("<title>Env Name"), + "env name in feed title" + ); + let header = header_of(&index).to_string(); + assert!( + !header.contains(""), + "no brand node in header" + ); + assert!(!header.contains("hu13-word"), "no word node in header"); + assert_eq!(header.matches("•").count(), 1, "logo • nav only"); + assert!(!header.contains("Env Name"), "header carries zero names"); // Flag beats env. set_env("HU13_REPO_NAME", Some("Env Name")); @@ -103,6 +126,10 @@ fn repo_name_chain_flag_beats_env_beats_dirname() { let index = read(&out, "index.html"); assert!(index.contains("Flag Name"), "flag wins"); assert!(!index.contains("Env Name"), "env suppressed by flag"); + assert!( + index.contains("og:title\" content=\"Flag Name\""), + "og:title follows flag" + ); // Neither -> dirname fallback, never blank. set_env("HU13_REPO_NAME", None); @@ -113,8 +140,12 @@ fn repo_name_chain_flag_beats_env_beats_dirname() { "dirname fallback" ); assert!( - index.contains(&format!("{dirname}")), - "brand fallback" + index.contains(&format!("

{dirname}

")), + "repo-head fallback" + ); + assert!( + !header_of(&index).contains(&dirname), + "header stays nameless" ); assert!(!index.contains("

"), "header never blank"); set_env("HU13_REPO_NAME", None); @@ -178,11 +209,18 @@ fn hostile_name_and_description_escape_everywhere() { } assert!(!feed.contains("