From bdc7a96f049af3bc84d3695f7a85c83f38ad8537 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 06:42:54 +0100 Subject: [PATCH] m6/logo: logo.svg + wordmark + ascii banner (supervisor: sharper banner) --- src/logo.rs | 82 ++++++++++++++++++ src/main.rs | 1 + static/ascii.txt | 6 ++ static/logo.svg | 8 ++ static/wordmark.svg | 8 ++ tests/logo.rs | 198 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 src/logo.rs create mode 100644 static/ascii.txt create mode 100644 static/logo.svg create mode 100644 static/wordmark.svg create mode 100644 tests/logo.rs diff --git a/src/logo.rs b/src/logo.rs new file mode 100644 index 0000000..6d79f2b --- /dev/null +++ b/src/logo.rs @@ -0,0 +1,82 @@ +//! hu13 logo + ASCII banner (m6, f0rge issue #15). +//! +//! The mark lives hand-written in `static/logo.svg` (square icon, reads at +//! 16px) plus `static/wordmark.svg` (horizontal lockup for the navbar); 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_pre`] renders the banner as an escaped `
` block (see
+//! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so
+//! escaping is a no-op today, but the wrapper keeps the invariant that any
+//! future art containing `<>&"` stays inert inside HTML.
+
+use crate::layout::esc;
+
+// TODO(m6+): stubs only — navbar (#16), favicon (#17), wiki README (#19)
+// and the agents page will call these; allow until wired.
+#[allow(dead_code)]
+/// ASCII banner, byte-for-byte `static/ascii.txt` (6 lines (5 art + tagline), <=60 cols).
+pub const ASCII_ART: &str = include_str!("../static/ascii.txt");
+
+#[allow(dead_code)]
+/// Square mark, byte-for-byte `static/logo.svg`.
+pub const LOGO_SVG: &str = include_str!("../static/logo.svg");
+
+#[allow(dead_code)]
+/// Horizontal lockup, byte-for-byte `static/wordmark.svg`.
+pub const WORDMARK_SVG: &str = include_str!("../static/wordmark.svg");
+
+/// Render the ASCII banner as an escaped `
` block.
+///
+/// `pre-scroll` matches the scroll-container convention in
+/// `static/style.css` so long lines never break the 360px layout.
+#[allow(dead_code)]
+#[must_use]
+pub fn ascii_pre() -> String {
+    format!(
+        "
\n{}
\n", + esc(ASCII_ART) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_const_is_small_banner() { + assert!(!ASCII_ART.is_empty(), "banner is non-empty"); + let lines: Vec<&str> = ASCII_ART.lines().collect(); + assert!( + (2..=12).contains(&lines.len()), + "2-12 lines, got {}", + lines.len() + ); + for line in &lines { + assert!(line.chars().count() <= 60, "line too wide: {line:?}"); + } + } + + #[test] + fn ascii_pre_wraps_escaped_pre() { + // The art is escapable-invariant: esc() is a no-op on it. + assert_eq!(esc(ASCII_ART), ASCII_ART, "art needs no escaping"); + let pre = ascii_pre(); + assert!(pre.contains(""); + assert!(pre.contains("pre-scroll"), "scroll wrapper kept"); + assert!(pre.contains(ASCII_ART), "banner inlined verbatim"); + assert!(pre.contains(&esc(ASCII_ART)), "inlined via esc()"); + assert!(!pre.contains(" + + hu13 + + + + + diff --git a/static/wordmark.svg b/static/wordmark.svg new file mode 100644 index 0000000..610c6cd --- /dev/null +++ b/static/wordmark.svg @@ -0,0 +1,8 @@ + + + hu13 + + + + hu13 + diff --git a/tests/logo.rs b/tests/logo.rs new file mode 100644 index 0000000..113ef4a --- /dev/null +++ b/tests/logo.rs @@ -0,0 +1,198 @@ +//! Logo suite (m6, f0rge issue #15): +//! `static/logo.svg` exists + valid XML + <2KB, wordmark variant, +//! ASCII banner limits, `
` rendering invariant.
+//!
+//! File-level suite (hu13 is a binary crate, so `src/logo.rs` unit tests
+//! cover the `ascii_pre()` helper itself; here we assert on disk).
+
+use std::path::PathBuf;
+
+fn manifest() -> PathBuf {
+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+fn read_static(name: &str) -> String {
+    let path = manifest().join("static").join(name);
+    std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("static/{name} exists"))
+}
+
+/// Minimal well-formedness check: balanced tags, quoted attributes, single
+/// root; skips comments, PIs and `` declarations. Enough to call the
+/// hand-written mark "valid XML" without a new dependency.
+fn check_well_formed(xml: &str) {
+    let bytes = xml.as_bytes();
+    let mut i = 0;
+    let mut stack: Vec = Vec::new();
+    let mut root_done = false;
+    while i < bytes.len() {
+        if bytes[i] != b'<' {
+            i += 1;
+            continue;
+        }
+        let rest = &xml[i..];
+        if rest.starts_with("").expect("unterminated comment");
+            i += end + 4;
+            continue;
+        }
+        if rest.starts_with("").expect("unterminated PI");
+            i += end + 2;
+            continue;
+        }
+        if rest.starts_with("').expect("unterminated declaration");
+            i += end + 1;
+            continue;
+        }
+        // Tag end, respecting quoted `>` inside attributes.
+        let mut j = i + 1;
+        let mut quote: Option = None;
+        while j < bytes.len() {
+            match (quote, bytes[j]) {
+                (Some(q), c) if c == q => quote = None,
+                (None, b'"' | b'\'') => quote = Some(bytes[j]),
+                (None, b'>') => break,
+                _ => {}
+            }
+            j += 1;
+        }
+        assert!(j < bytes.len(), "unterminated tag near {rest:?}");
+        let tag = &xml[i..=j];
+        if let Some(close) = tag.strip_prefix("').trim().to_string();
+            let open = stack.pop().expect("closing tag without opener");
+            assert_eq!(open, name, "mismatched tag: <{open}> vs ");
+            if stack.is_empty() {
+                root_done = true;
+            }
+        } else {
+            let self_close = tag.ends_with("/>");
+            let mut inner = &tag[1..tag.len() - 1];
+            inner = inner.strip_suffix('/').unwrap_or(inner);
+            let name: &str = inner
+                .split(|c: char| c.is_whitespace() || c == '/')
+                .next()
+                .unwrap_or("");
+            assert!(!name.is_empty(), "empty tag name in {tag:?}");
+            assert!(
+                name.bytes()
+                    .next()
+                    .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_' || c == b':'),
+                "bad tag name {name:?}"
+            );
+            if self_close {
+                if stack.is_empty() {
+                    root_done = true;
+                }
+            } else {
+                assert!(!root_done, "second root element <{name}>");
+                stack.push(name.to_string());
+            }
+        }
+        i = j + 1;
+    }
+    assert!(stack.is_empty(), "unclosed tags: {stack:?}");
+    assert!(root_done, "no root element");
+}
+
+fn assert_sharp_no_gradient(name: &str, svg: &str) {
+    let lower = svg.to_lowercase();
+    for bad in [
+        "rx=",
+        "ry=",
+        " 100, "logo.svg is {len} bytes, suspiciously small");
+    let svg = read_static("logo.svg");
+    check_well_formed(&svg);
+    assert!(svg.contains(" root");
+    assert!(
+        svg.contains("http://www.w3.org/2000/svg"),
+        "svg namespace present"
+    );
+    assert!(svg.contains("viewBox"), "scales via viewBox (16px + 32px)");
+    for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {
+        assert!(svg.contains(color), "Tokyo Night palette keeps {color}");
+    }
+    assert_sharp_no_gradient("logo.svg", &svg);
+}
+
+#[test]
+fn wordmark_variant_exists_tiny() {
+    let path = manifest().join("static").join("wordmark.svg");
+    let len = std::fs::metadata(&path)
+        .expect("static/wordmark.svg exists")
+        .len();
+    assert!(len < 2048, "wordmark.svg is {len} bytes, budget is 2KB");
+    let svg = read_static("wordmark.svg");
+    check_well_formed(&svg);
+    assert!(svg.contains("hu13"), "wordmark names hu13");
+    assert_sharp_no_gradient("wordmark.svg", &svg);
+}
+
+#[test]
+fn ascii_banner_limits_and_aesthetic() {
+    let art = read_static("ascii.txt");
+    assert!(!art.trim().is_empty(), "banner is non-empty");
+    let lines: Vec<&str> = art.lines().collect();
+    assert!(
+        lines.len() <= 12,
+        "banner is {} lines, cap is 12",
+        lines.len()
+    );
+    assert!(lines.len() >= 2, "banner is a banner, not one line");
+    let mut saw_pipe = false;
+    let mut saw_hyphen = false;
+    let mut saw_slash = false;
+    for line in &lines {
+        assert!(line.chars().count() <= 60, "banner line too wide: {line:?}");
+        for ch in line.chars() {
+            saw_pipe |= ch == '|';
+            saw_hyphen |= ch == '-';
+            saw_slash |= ch == '/';
+            assert!(
+                matches!(ch, '|' | '-' | '/' | '_' | '+' | ' '),
+                "sharp-corner aesthetic only (| - / _ + space), got {ch:?}"
+            );
+        }
+    }
+    assert!(
+        saw_pipe && saw_hyphen && saw_slash,
+        "uses pipes+hyphens+slashes"
+    );
+}
+
+#[test]
+fn ascii_renders_inside_pre_unescaped_except_esc() {
+    // The banner has no `<>&"` so `esc()` must leave it byte-identical;
+    // wrapping it in `
` is therefore exact (unit tests in `src/logo.rs`
+    // assert the same on the `ascii_pre()` helper itself).
+    let art = read_static("ascii.txt");
+    assert!(!art.contains(['<', '>', '&', '"']), "nothing to escape");
+    let pre = format!("
\n{art}
"); + assert!(pre.contains("
"), "inside 
");
+    assert!(pre.contains(&art), "verbatim, unescaped-except-esc()");
+}
-- 
2.55.0