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