#16 m6/logo

mergedpassopened
details
branches
master ← m6/logo
updated
id
60f08ea3-9c44-410d-8372-3bbc787892c6

Patch

src/logo.rs+82 −0
diff --git a/src/logo.rs b/src/logo.rsnew file mode 100644index 0000000..6d79f2b--- /dev/null+++ b/src/logo.rs@@ -0,0 +1,82 @@1+//! hu13 logo + ASCII banner (m6, f0rge issue #15).2+//!3+//! The mark lives hand-written in `static/logo.svg` (square icon, reads at4+//! 16px) plus `static/wordmark.svg` (horizontal lockup for the navbar); the5+//! text banner lives in `static/ascii.txt`.6+//!7+//! [`ASCII_ART`], [`LOGO_SVG`] and [`WORDMARK_SVG`] re-expose those static8+//! files for later issues (navbar #16, favicon #17, wiki README #19, agents9+//! page) with no build step.10+//!11+//! [`ascii_pre`] renders the banner as an escaped `<pre>` block (see12+//! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so13+//! escaping is a no-op today, but the wrapper keeps the invariant that any14+//! future art containing `<>&"` stays inert inside HTML.15+16+use crate::layout::esc;17+18+// TODO(m6+): stubs only — navbar (#16), favicon (#17), wiki README (#19)19+// and the agents page will call these; allow until wired.20+#[allow(dead_code)]21+/// ASCII banner, byte-for-byte `static/ascii.txt` (6 lines (5 art + tagline), <=60 cols).22+pub const ASCII_ART: &str = include_str!("../static/ascii.txt");23+24+#[allow(dead_code)]25+/// Square mark, byte-for-byte `static/logo.svg`.26+pub const LOGO_SVG: &str = include_str!("../static/logo.svg");27+28+#[allow(dead_code)]29+/// Horizontal lockup, byte-for-byte `static/wordmark.svg`.30+pub const WORDMARK_SVG: &str = include_str!("../static/wordmark.svg");31+32+/// Render the ASCII banner as an escaped `<pre>` block.33+///34+/// `pre-scroll` matches the scroll-container convention in35+/// `static/style.css` so long lines never break the 360px layout.36+#[allow(dead_code)]37+#[must_use]38+pub fn ascii_pre() -> String {39+    format!(40+        "<div class=\"pre-scroll\"><pre class=\"logo-ascii\">\n{}</pre></div>\n",41+        esc(ASCII_ART)42+    )43+}44+45+#[cfg(test)]46+mod tests {47+    use super::*;48+49+    #[test]50+    fn ascii_const_is_small_banner() {51+        assert!(!ASCII_ART.is_empty(), "banner is non-empty");52+        let lines: Vec<&str> = ASCII_ART.lines().collect();53+        assert!(54+            (2..=12).contains(&lines.len()),55+            "2-12 lines, got {}",56+            lines.len()57+        );58+        for line in &lines {59+            assert!(line.chars().count() <= 60, "line too wide: {line:?}");60+        }61+    }62+63+    #[test]64+    fn ascii_pre_wraps_escaped_pre() {65+        // The art is escapable-invariant: esc() is a no-op on it.66+        assert_eq!(esc(ASCII_ART), ASCII_ART, "art needs no escaping");67+        let pre = ascii_pre();68+        assert!(pre.contains("<pre"), "renders inside <pre>");69+        assert!(pre.contains("pre-scroll"), "scroll wrapper kept");70+        assert!(pre.contains(ASCII_ART), "banner inlined verbatim");71+        assert!(pre.contains(&esc(ASCII_ART)), "inlined via esc()");72+        assert!(!pre.contains("<script"), "no script smuggling");73+    }74+75+    #[test]76+    fn svg_consts_match_tiny_marks() {77+        for (name, svg) in [("logo", LOGO_SVG), ("wordmark", WORDMARK_SVG)] {78+            assert!(svg.contains("<svg"), "{name} const holds svg");79+            assert!(svg.len() < 2048, "{name} const stays tiny");80+        }81+    }82+}
src/main.rs+1 −0
diff --git a/src/main.rs b/src/main.rsindex ee41cba..80bc1fc 100644--- a/src/main.rs+++ b/src/main.rs@@ -7,6 +7,7 @@ mod forge;77 mod gitx;88 mod inbox;99 mod layout;10+mod logo;1011 mod releases;1112 mod serve;1213 mod wiki;
static/ascii.txt+6 −0
diff --git a/static/ascii.txt b/static/ascii.txtnew file mode 100644index 0000000..f9b0f29--- /dev/null+++ b/static/ascii.txt@@ -0,0 +1,6 @@1+|             |    ___2+|___          |       |3+|   | |   |   |    ___|4+|   | |   |   |       |5+|   | |___|   |   |___|6+hu13 - one repo, one hub
static/logo.svg+8 −0
diff --git a/static/logo.svg b/static/logo.svgnew file mode 100644index 0000000..35a4426--- /dev/null+++ b/static/logo.svg@@ -0,0 +1,8 @@1+<?xml version="1.0" encoding="UTF-8"?>2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">3+  <title>hu13</title>4+  <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>5+  <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>6+  <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>7+  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>8+</svg>
static/wordmark.svg+8 −0
diff --git a/static/wordmark.svg b/static/wordmark.svgnew file mode 100644index 0000000..610c6cd--- /dev/null+++ b/static/wordmark.svg@@ -0,0 +1,8 @@1+<?xml version="1.0" encoding="UTF-8"?>2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 32" width="128" height="32" role="img" aria-label="hu13 wordmark">3+  <title>hu13</title>4+  <rect x="0" y="0" width="128" height="32" fill="#1a1b26"/>5+  <polygon points="16,4 28,16 16,28 4,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>6+  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>7+  <text x="36" y="22" font-family="monospace" font-size="16" font-weight="bold" fill="#c0caf5">hu13</text>8+</svg>
tests/logo.rs+198 −1
diff --git a/tests/logo.rs b/tests/logo.rsnew file mode 100644index 0000000..113ef4a--- /dev/null+++ b/tests/logo.rs@@ -0,0 +1,198 @@1+//! Logo suite (m6, f0rge issue #15):2+//! `static/logo.svg` exists + valid XML + <2KB, wordmark variant,3+//! ASCII banner limits, `<pre>` rendering invariant.4+//!5+//! File-level suite (hu13 is a binary crate, so `src/logo.rs` unit tests6+//! cover the `ascii_pre()` helper itself; here we assert on disk).7+8+use std::path::PathBuf;9+10+fn manifest() -> PathBuf {11+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))12+}13+14+fn read_static(name: &str) -> String {15+    let path = manifest().join("static").join(name);16+    std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("static/{name} exists"))17+}18+19+/// Minimal well-formedness check: balanced tags, quoted attributes, single20+/// root; skips comments, PIs and `<!...>` declarations. Enough to call the21+/// hand-written mark "valid XML" without a new dependency.22+fn check_well_formed(xml: &str) {23+    let bytes = xml.as_bytes();24+    let mut i = 0;25+    let mut stack: Vec<String> = Vec::new();26+    let mut root_done = false;27+    while i < bytes.len() {28+        if bytes[i] != b'<' {29+            i += 1;30+            continue;31+        }32+        let rest = &xml[i..];33+        if rest.starts_with("<!--") {34+            let end = rest.find("-->").expect("unterminated comment");35+            i += end + 4;36+            continue;37+        }38+        if rest.starts_with("<?") {39+            let end = rest.find("?>").expect("unterminated PI");40+            i += end + 2;41+            continue;42+        }43+        if rest.starts_with("<!") {44+            let end = rest.find('>').expect("unterminated declaration");45+            i += end + 1;46+            continue;47+        }48+        // Tag end, respecting quoted `>` inside attributes.49+        let mut j = i + 1;50+        let mut quote: Option<u8> = None;51+        while j < bytes.len() {52+            match (quote, bytes[j]) {53+                (Some(q), c) if c == q => quote = None,54+                (None, b'"' | b'\'') => quote = Some(bytes[j]),55+                (None, b'>') => break,56+                _ => {}57+            }58+            j += 1;59+        }60+        assert!(j < bytes.len(), "unterminated tag near {rest:?}");61+        let tag = &xml[i..=j];62+        if let Some(close) = tag.strip_prefix("</") {63+            let name = close.trim_end_matches('>').trim().to_string();64+            let open = stack.pop().expect("closing tag without opener");65+            assert_eq!(open, name, "mismatched tag: <{open}> vs </{name}>");66+            if stack.is_empty() {67+                root_done = true;68+            }69+        } else {70+            let self_close = tag.ends_with("/>");71+            let mut inner = &tag[1..tag.len() - 1];72+            inner = inner.strip_suffix('/').unwrap_or(inner);73+            let name: &str = inner74+                .split(|c: char| c.is_whitespace() || c == '/')75+                .next()76+                .unwrap_or("");77+            assert!(!name.is_empty(), "empty tag name in {tag:?}");78+            assert!(79+                name.bytes()80+                    .next()81+                    .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_' || c == b':'),82+                "bad tag name {name:?}"83+            );84+            if self_close {85+                if stack.is_empty() {86+                    root_done = true;87+                }88+            } else {89+                assert!(!root_done, "second root element <{name}>");90+                stack.push(name.to_string());91+            }92+        }93+        i = j + 1;94+    }95+    assert!(stack.is_empty(), "unclosed tags: {stack:?}");96+    assert!(root_done, "no root element");97+}98+99+fn assert_sharp_no_gradient(name: &str, svg: &str) {100+    let lower = svg.to_lowercase();101+    for bad in [102+        "rx=",103+        "ry=",104+        "<circle",105+        "<ellipse",106+        "border-radius",107+        "linecap=\"round\"",108+        "linejoin=\"round\"",109+    ] {110+        assert!(111+            !lower.contains(bad),112+            "{name}: no rounded corners ({bad}), sharp geometry only"113+        );114+    }115+    assert!(116+        !lower.contains("gradient"),117+        "{name}: no gradients (must stay legible at 16px)"118+    );119+}120+121+#[test]122+fn logo_svg_exists_valid_tiny() {123+    let path = manifest().join("static").join("logo.svg");124+    let len = std::fs::metadata(&path)125+        .expect("static/logo.svg exists")126+        .len();127+    assert!(len < 2048, "logo.svg is {len} bytes, budget is 2KB");128+    assert!(len > 100, "logo.svg is {len} bytes, suspiciously small");129+    let svg = read_static("logo.svg");130+    check_well_formed(&svg);131+    assert!(svg.contains("<svg"), "has an <svg> root");132+    assert!(133+        svg.contains("http://www.w3.org/2000/svg"),134+        "svg namespace present"135+    );136+    assert!(svg.contains("viewBox"), "scales via viewBox (16px + 32px)");137+    for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {138+        assert!(svg.contains(color), "Tokyo Night palette keeps {color}");139+    }140+    assert_sharp_no_gradient("logo.svg", &svg);141+}142+143+#[test]144+fn wordmark_variant_exists_tiny() {145+    let path = manifest().join("static").join("wordmark.svg");146+    let len = std::fs::metadata(&path)147+        .expect("static/wordmark.svg exists")148+        .len();149+    assert!(len < 2048, "wordmark.svg is {len} bytes, budget is 2KB");150+    let svg = read_static("wordmark.svg");151+    check_well_formed(&svg);152+    assert!(svg.contains("hu13"), "wordmark names hu13");153+    assert_sharp_no_gradient("wordmark.svg", &svg);154+}155+156+#[test]157+fn ascii_banner_limits_and_aesthetic() {158+    let art = read_static("ascii.txt");159+    assert!(!art.trim().is_empty(), "banner is non-empty");160+    let lines: Vec<&str> = art.lines().collect();161+    assert!(162+        lines.len() <= 12,163+        "banner is {} lines, cap is 12",164+        lines.len()165+    );166+    assert!(lines.len() >= 2, "banner is a banner, not one line");167+    let mut saw_pipe = false;168+    let mut saw_hyphen = false;169+    let mut saw_slash = false;170+    for line in &lines {171+        assert!(line.chars().count() <= 60, "banner line too wide: {line:?}");172+        for ch in line.chars() {173+            saw_pipe |= ch == '|';174+            saw_hyphen |= ch == '-';175+            saw_slash |= ch == '/';176+            assert!(177+                matches!(ch, '|' | '-' | '/' | '_' | '+' | ' '),178+                "sharp-corner aesthetic only (| - / _ + space), got {ch:?}"179+            );180+        }181+    }182+    assert!(183+        saw_pipe && saw_hyphen && saw_slash,184+        "uses pipes+hyphens+slashes"185+    );186+}187+188+#[test]189+fn ascii_renders_inside_pre_unescaped_except_esc() {190+    // The banner has no `<>&"` so `esc()` must leave it byte-identical;191+    // wrapping it in `<pre>` is therefore exact (unit tests in `src/logo.rs`192+    // assert the same on the `ascii_pre()` helper itself).193+    let art = read_static("ascii.txt");194+    assert!(!art.contains(['<', '>', '&', '"']), "nothing to escape");195+    let pre = format!("<div class=\"pre-scroll\"><pre>\n{art}</pre></div>");196+    assert!(pre.contains("<pre>"), "inside <pre>");197+    assert!(pre.contains(&art), "verbatim, unescaped-except-esc()");198+}0-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls