.forge/prs/60f08ea3-9c44-410d-8372-3bbc787892c6/patch.diff 12.7 KiB 367 lines Raw
1 From bdc7a96f049af3bc84d3695f7a85c83f38ad8537 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:42:54 +01004 Subject: [PATCH] m6/logo: logo.svg + wordmark + ascii banner (supervisor:5  sharper banner)6 7 ---8  src/logo.rs         |  82 ++++++++++++++++++9  src/main.rs         |   1 +10  static/ascii.txt    |   6 ++11  static/logo.svg     |   8 ++12  static/wordmark.svg |   8 ++13  tests/logo.rs       | 198 ++++++++++++++++++++++++++++++++++++++++++++14  6 files changed, 303 insertions(+)15  create mode 100644 src/logo.rs16  create mode 100644 static/ascii.txt17  create mode 100644 static/logo.svg18  create mode 100644 static/wordmark.svg19  create mode 100644 tests/logo.rs20 21 diff --git a/src/logo.rs b/src/logo.rs22 new file mode 10064423 index 0000000..6d79f2b24 --- /dev/null25 +++ b/src/logo.rs26 @@ -0,0 +1,82 @@27 +//! hu13 logo + ASCII banner (m6, f0rge issue #15).28 +//!29 +//! The mark lives hand-written in `static/logo.svg` (square icon, reads at30 +//! 16px) plus `static/wordmark.svg` (horizontal lockup for the navbar); the31 +//! text banner lives in `static/ascii.txt`.32 +//!33 +//! [`ASCII_ART`], [`LOGO_SVG`] and [`WORDMARK_SVG`] re-expose those static34 +//! files for later issues (navbar #16, favicon #17, wiki README #19, agents35 +//! page) with no build step.36 +//!37 +//! [`ascii_pre`] renders the banner as an escaped `<pre>` block (see38 +//! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so39 +//! escaping is a no-op today, but the wrapper keeps the invariant that any40 +//! future art containing `<>&"` stays inert inside HTML.41 +42 +use crate::layout::esc;43 +44 +// TODO(m6+): stubs only — navbar (#16), favicon (#17), wiki README (#19)45 +// and the agents page will call these; allow until wired.46 +#[allow(dead_code)]47 +/// ASCII banner, byte-for-byte `static/ascii.txt` (6 lines (5 art + tagline), <=60 cols).48 +pub const ASCII_ART: &str = include_str!("../static/ascii.txt");49 +50 +#[allow(dead_code)]51 +/// Square mark, byte-for-byte `static/logo.svg`.52 +pub const LOGO_SVG: &str = include_str!("../static/logo.svg");53 +54 +#[allow(dead_code)]55 +/// Horizontal lockup, byte-for-byte `static/wordmark.svg`.56 +pub const WORDMARK_SVG: &str = include_str!("../static/wordmark.svg");57 +58 +/// Render the ASCII banner as an escaped `<pre>` block.59 +///60 +/// `pre-scroll` matches the scroll-container convention in61 +/// `static/style.css` so long lines never break the 360px layout.62 +#[allow(dead_code)]63 +#[must_use]64 +pub fn ascii_pre() -> String {65 +    format!(66 +        "<div class=\"pre-scroll\"><pre class=\"logo-ascii\">\n{}</pre></div>\n",67 +        esc(ASCII_ART)68 +    )69 +}70 +71 +#[cfg(test)]72 +mod tests {73 +    use super::*;74 +75 +    #[test]76 +    fn ascii_const_is_small_banner() {77 +        assert!(!ASCII_ART.is_empty(), "banner is non-empty");78 +        let lines: Vec<&str> = ASCII_ART.lines().collect();79 +        assert!(80 +            (2..=12).contains(&lines.len()),81 +            "2-12 lines, got {}",82 +            lines.len()83 +        );84 +        for line in &lines {85 +            assert!(line.chars().count() <= 60, "line too wide: {line:?}");86 +        }87 +    }88 +89 +    #[test]90 +    fn ascii_pre_wraps_escaped_pre() {91 +        // The art is escapable-invariant: esc() is a no-op on it.92 +        assert_eq!(esc(ASCII_ART), ASCII_ART, "art needs no escaping");93 +        let pre = ascii_pre();94 +        assert!(pre.contains("<pre"), "renders inside <pre>");95 +        assert!(pre.contains("pre-scroll"), "scroll wrapper kept");96 +        assert!(pre.contains(ASCII_ART), "banner inlined verbatim");97 +        assert!(pre.contains(&esc(ASCII_ART)), "inlined via esc()");98 +        assert!(!pre.contains("<script"), "no script smuggling");99 +    }100 +101 +    #[test]102 +    fn svg_consts_match_tiny_marks() {103 +        for (name, svg) in [("logo", LOGO_SVG), ("wordmark", WORDMARK_SVG)] {104 +            assert!(svg.contains("<svg"), "{name} const holds svg");105 +            assert!(svg.len() < 2048, "{name} const stays tiny");106 +        }107 +    }108 +}109 diff --git a/src/main.rs b/src/main.rs110 index ee41cba..80bc1fc 100644111 --- a/src/main.rs112 +++ b/src/main.rs113 @@ -7,6 +7,7 @@ mod forge;114  mod gitx;115  mod inbox;116  mod layout;117 +mod logo;118  mod releases;119  mod serve;120  mod wiki;121 diff --git a/static/ascii.txt b/static/ascii.txt122 new file mode 100644123 index 0000000..f9b0f29124 --- /dev/null125 +++ b/static/ascii.txt126 @@ -0,0 +1,6 @@127 +|             |    ___128 +|___          |       |129 +|   | |   |   |    ___|130 +|   | |   |   |       |131 +|   | |___|   |   |___|132 +hu13 - one repo, one hub133 diff --git a/static/logo.svg b/static/logo.svg134 new file mode 100644135 index 0000000..35a4426136 --- /dev/null137 +++ b/static/logo.svg138 @@ -0,0 +1,8 @@139 +<?xml version="1.0" encoding="UTF-8"?>140 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">141 +  <title>hu13</title>142 +  <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>143 +  <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>144 +  <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>145 +  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>146 +</svg>147 diff --git a/static/wordmark.svg b/static/wordmark.svg148 new file mode 100644149 index 0000000..610c6cd150 --- /dev/null151 +++ b/static/wordmark.svg152 @@ -0,0 +1,8 @@153 +<?xml version="1.0" encoding="UTF-8"?>154 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 32" width="128" height="32" role="img" aria-label="hu13 wordmark">155 +  <title>hu13</title>156 +  <rect x="0" y="0" width="128" height="32" fill="#1a1b26"/>157 +  <polygon points="16,4 28,16 16,28 4,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>158 +  <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>159 +  <text x="36" y="22" font-family="monospace" font-size="16" font-weight="bold" fill="#c0caf5">hu13</text>160 +</svg>161 diff --git a/tests/logo.rs b/tests/logo.rs162 new file mode 100644163 index 0000000..113ef4a164 --- /dev/null165 +++ b/tests/logo.rs166 @@ -0,0 +1,198 @@167 +//! Logo suite (m6, f0rge issue #15):168 +//! `static/logo.svg` exists + valid XML + <2KB, wordmark variant,169 +//! ASCII banner limits, `<pre>` rendering invariant.170 +//!171 +//! File-level suite (hu13 is a binary crate, so `src/logo.rs` unit tests172 +//! cover the `ascii_pre()` helper itself; here we assert on disk).173 +174 +use std::path::PathBuf;175 +176 +fn manifest() -> PathBuf {177 +    PathBuf::from(env!("CARGO_MANIFEST_DIR"))178 +}179 +180 +fn read_static(name: &str) -> String {181 +    let path = manifest().join("static").join(name);182 +    std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("static/{name} exists"))183 +}184 +185 +/// Minimal well-formedness check: balanced tags, quoted attributes, single186 +/// root; skips comments, PIs and `<!...>` declarations. Enough to call the187 +/// hand-written mark "valid XML" without a new dependency.188 +fn check_well_formed(xml: &str) {189 +    let bytes = xml.as_bytes();190 +    let mut i = 0;191 +    let mut stack: Vec<String> = Vec::new();192 +    let mut root_done = false;193 +    while i < bytes.len() {194 +        if bytes[i] != b'<' {195 +            i += 1;196 +            continue;197 +        }198 +        let rest = &xml[i..];199 +        if rest.starts_with("<!--") {200 +            let end = rest.find("-->").expect("unterminated comment");201 +            i += end + 4;202 +            continue;203 +        }204 +        if rest.starts_with("<?") {205 +            let end = rest.find("?>").expect("unterminated PI");206 +            i += end + 2;207 +            continue;208 +        }209 +        if rest.starts_with("<!") {210 +            let end = rest.find('>').expect("unterminated declaration");211 +            i += end + 1;212 +            continue;213 +        }214 +        // Tag end, respecting quoted `>` inside attributes.215 +        let mut j = i + 1;216 +        let mut quote: Option<u8> = None;217 +        while j < bytes.len() {218 +            match (quote, bytes[j]) {219 +                (Some(q), c) if c == q => quote = None,220 +                (None, b'"' | b'\'') => quote = Some(bytes[j]),221 +                (None, b'>') => break,222 +                _ => {}223 +            }224 +            j += 1;225 +        }226 +        assert!(j < bytes.len(), "unterminated tag near {rest:?}");227 +        let tag = &xml[i..=j];228 +        if let Some(close) = tag.strip_prefix("</") {229 +            let name = close.trim_end_matches('>').trim().to_string();230 +            let open = stack.pop().expect("closing tag without opener");231 +            assert_eq!(open, name, "mismatched tag: <{open}> vs </{name}>");232 +            if stack.is_empty() {233 +                root_done = true;234 +            }235 +        } else {236 +            let self_close = tag.ends_with("/>");237 +            let mut inner = &tag[1..tag.len() - 1];238 +            inner = inner.strip_suffix('/').unwrap_or(inner);239 +            let name: &str = inner240 +                .split(|c: char| c.is_whitespace() || c == '/')241 +                .next()242 +                .unwrap_or("");243 +            assert!(!name.is_empty(), "empty tag name in {tag:?}");244 +            assert!(245 +                name.bytes()246 +                    .next()247 +                    .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_' || c == b':'),248 +                "bad tag name {name:?}"249 +            );250 +            if self_close {251 +                if stack.is_empty() {252 +                    root_done = true;253 +                }254 +            } else {255 +                assert!(!root_done, "second root element <{name}>");256 +                stack.push(name.to_string());257 +            }258 +        }259 +        i = j + 1;260 +    }261 +    assert!(stack.is_empty(), "unclosed tags: {stack:?}");262 +    assert!(root_done, "no root element");263 +}264 +265 +fn assert_sharp_no_gradient(name: &str, svg: &str) {266 +    let lower = svg.to_lowercase();267 +    for bad in [268 +        "rx=",269 +        "ry=",270 +        "<circle",271 +        "<ellipse",272 +        "border-radius",273 +        "linecap=\"round\"",274 +        "linejoin=\"round\"",275 +    ] {276 +        assert!(277 +            !lower.contains(bad),278 +            "{name}: no rounded corners ({bad}), sharp geometry only"279 +        );280 +    }281 +    assert!(282 +        !lower.contains("gradient"),283 +        "{name}: no gradients (must stay legible at 16px)"284 +    );285 +}286 +287 +#[test]288 +fn logo_svg_exists_valid_tiny() {289 +    let path = manifest().join("static").join("logo.svg");290 +    let len = std::fs::metadata(&path)291 +        .expect("static/logo.svg exists")292 +        .len();293 +    assert!(len < 2048, "logo.svg is {len} bytes, budget is 2KB");294 +    assert!(len > 100, "logo.svg is {len} bytes, suspiciously small");295 +    let svg = read_static("logo.svg");296 +    check_well_formed(&svg);297 +    assert!(svg.contains("<svg"), "has an <svg> root");298 +    assert!(299 +        svg.contains("http://www.w3.org/2000/svg"),300 +        "svg namespace present"301 +    );302 +    assert!(svg.contains("viewBox"), "scales via viewBox (16px + 32px)");303 +    for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {304 +        assert!(svg.contains(color), "Tokyo Night palette keeps {color}");305 +    }306 +    assert_sharp_no_gradient("logo.svg", &svg);307 +}308 +309 +#[test]310 +fn wordmark_variant_exists_tiny() {311 +    let path = manifest().join("static").join("wordmark.svg");312 +    let len = std::fs::metadata(&path)313 +        .expect("static/wordmark.svg exists")314 +        .len();315 +    assert!(len < 2048, "wordmark.svg is {len} bytes, budget is 2KB");316 +    let svg = read_static("wordmark.svg");317 +    check_well_formed(&svg);318 +    assert!(svg.contains("hu13"), "wordmark names hu13");319 +    assert_sharp_no_gradient("wordmark.svg", &svg);320 +}321 +322 +#[test]323 +fn ascii_banner_limits_and_aesthetic() {324 +    let art = read_static("ascii.txt");325 +    assert!(!art.trim().is_empty(), "banner is non-empty");326 +    let lines: Vec<&str> = art.lines().collect();327 +    assert!(328 +        lines.len() <= 12,329 +        "banner is {} lines, cap is 12",330 +        lines.len()331 +    );332 +    assert!(lines.len() >= 2, "banner is a banner, not one line");333 +    let mut saw_pipe = false;334 +    let mut saw_hyphen = false;335 +    let mut saw_slash = false;336 +    for line in &lines {337 +        assert!(line.chars().count() <= 60, "banner line too wide: {line:?}");338 +        for ch in line.chars() {339 +            saw_pipe |= ch == '|';340 +            saw_hyphen |= ch == '-';341 +            saw_slash |= ch == '/';342 +            assert!(343 +                matches!(ch, '|' | '-' | '/' | '_' | '+' | ' '),344 +                "sharp-corner aesthetic only (| - / _ + space), got {ch:?}"345 +            );346 +        }347 +    }348 +    assert!(349 +        saw_pipe && saw_hyphen && saw_slash,350 +        "uses pipes+hyphens+slashes"351 +    );352 +}353 +354 +#[test]355 +fn ascii_renders_inside_pre_unescaped_except_esc() {356 +    // The banner has no `<>&"` so `esc()` must leave it byte-identical;357 +    // wrapping it in `<pre>` is therefore exact (unit tests in `src/logo.rs`358 +    // assert the same on the `ascii_pre()` helper itself).359 +    let art = read_static("ascii.txt");360 +    assert!(!art.contains(['<', '>', '&', '"']), "nothing to escape");361 +    let pre = format!("<div class=\"pre-scroll\"><pre>\n{art}</pre></div>");362 +    assert!(pre.contains("<pre>"), "inside <pre>");363 +    assert!(pre.contains(&art), "verbatim, unescaped-except-esc()");364 +}365 -- 366 2.55.0367