From bf991339b9474f18e33f3684c79a2c2e6013e90e Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 08:23:50 +0100 Subject: [PATCH] m6/owner-logo: owner block-art ansi+plain, tty banner --- src/logo.rs | 126 ++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 6 +++ static/ascii.txt | 17 ++++-- static/logo-ansi.txt | 12 +++++ tests/logo.rs | 110 ++++++++++++++++++++++++++++++------- 5 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 static/logo-ansi.txt diff --git a/src/logo.rs b/src/logo.rs index f580b2b..f71358c 100644 --- a/src/logo.rs +++ b/src/logo.rs @@ -1,29 +1,44 @@ -//! hu13 logo + ASCII banner (m6, f0rge issues #15 + #27 + #28). +//! hu13 logo + ASCII banner (m6, f0rge issues #15 + #26 + #27 + #28). //! //! The lockup lives hand-written in `static/logo.svg` (diamond-hub mark + //! hu13 lettered as , 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`. +//! the text banner lives in `static/ascii.txt` (owner block-art, 12 lines, +//! stripped from `static/logo-ansi.txt`). //! -//! [`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_ART`], [`LOGO_ANSI_RAW`], [`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
+//! [`crate::layout::esc`]): the art itself is block glyphs plus spaces so
 //! escaping is a no-op today, but the wrapper keeps the invariant that any
 //! future art containing `<>&"` stays inert inside HTML.
+//!
+//! `static/logo-ansi.txt` stores the owner's 12-line ANSI art with literal
+//! `[...m` codes and NO raw ESC bytes (repo stays ESC-free); terminal use
+//! goes through [`logo_ansi_for_tty`], which prefixes each SGR code with
+//! ESC at runtime so a tty renders colors while pipes stay plain.
 
 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` (5 lines, <=60 cols).
+/// ASCII banner, byte-for-byte `static/ascii.txt` (owner block-art,
+/// 12 lines, 42 cols, block glyphs + spaces).
 pub const ASCII_ART: &str = include_str!("../static/ascii.txt");
 
+#[allow(dead_code)]
+/// Owner ANSI block-art, byte-for-byte `static/logo-ansi.txt` (12 lines).
+///
+/// Stored with literal `[...m` SGR codes and NO raw ESC bytes (see raw
+/// file); use [`logo_ansi_for_tty`] for terminal output and
+/// [`strip_ansi_sgr`] for the plain render.
+pub const LOGO_ANSI_RAW: &str = include_str!("../static/logo-ansi.txt");
+
 #[allow(dead_code)]
 /// Header lockup, byte-for-byte `static/logo.svg` (mark + hu13 as ).
 pub const LOGO_SVG: &str = include_str!("../static/logo.svg");
@@ -50,6 +65,80 @@ pub fn ascii_pre() -> String {
     )
 }
 
+/// Strip SGR color codes from owner ANSI art.
+///
+/// Removes literal `[...m` sequences (`[` + digits/`;` + `m`) plus the same
+/// sequences with a leading ESC byte (`\x1b[...m`, as produced by
+/// [`interpret_ansi_sgr`]), leaving the plain block glyphs. Equivalent to
+/// `sed 's/\[[0-9;]*m//g'` on the ESC-free stored file.
+#[allow(dead_code)]
+#[must_use]
+pub fn strip_ansi_sgr(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = String::with_capacity(s.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        let mut j = i;
+        // Optional single ESC prefix (interpreted form).
+        if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'[' {
+            j += 1;
+        }
+        if bytes[j] == b'[' {
+            let mut k = j + 1;
+            while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {
+                k += 1;
+            }
+            if k < bytes.len() && bytes[k] == b'm' && k > j + 1 {
+                i = k + 1;
+                continue;
+            }
+        }
+        let ch = s[i..].chars().next().expect("char boundary");
+        out.push(ch);
+        i += ch.len_utf8();
+    }
+    out
+}
+
+/// Prefix each literal SGR code (`[...m`) with ESC for tty output.
+///
+/// The stored file is ESC-free so the repo never holds raw ESC bytes;
+/// this converts at runtime so a terminal renders magenta/cyan/grey.
+/// A `[` not followed by digits/`;` + `m` is left alone.
+#[allow(dead_code)]
+#[must_use]
+pub fn interpret_ansi_sgr(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = String::with_capacity(s.len() + 64);
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'[' {
+            let mut k = i + 1;
+            while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {
+                k += 1;
+            }
+            if k < bytes.len() && bytes[k] == b'm' && k > i + 1 {
+                out.push('\u{1b}');
+                let ch = s[i..].chars().next().expect("char boundary");
+                out.push(ch);
+                i += ch.len_utf8();
+                continue;
+            }
+        }
+        let ch = s[i..].chars().next().expect("char boundary");
+        out.push(ch);
+        i += ch.len_utf8();
+    }
+    out
+}
+
+/// Owner ANSI art interpreted for a tty (ESC-prefixed SGR codes).
+#[allow(dead_code)]
+#[must_use]
+pub fn logo_ansi_for_tty() -> String {
+    interpret_ansi_sgr(LOGO_ANSI_RAW)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -80,6 +169,29 @@ mod tests {
         assert!(!pre.contains(" i32 {
     match rest[0].as_str() {
         "--version" | "-V" | "version" => {
             println!("hu13 {VERSION}");
+            // Owner logo (#26): on a tty also show the ANSI block-art
+            // interpreted (ESC-prefixed so colors render); pipes stay a
+            // single plain version line. Zero-dep via IsTerminal.
+            if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
+                print!("{}", crate::logo::logo_ansi_for_tty());
+            }
             0
         }
         "--help" | "-h" | "help" => {
diff --git a/static/ascii.txt b/static/ascii.txt
index 1e39422..020ad10 100644
--- a/static/ascii.txt
+++ b/static/ascii.txt
@@ -1,5 +1,12 @@
-|             /|   ___
-|___           |  |   |
-|   | |   |    |  |---
-|   | |   |    |  |   |
-|   | |___|  __|_ |___|
+ ███   ███  ███   ███  █████      ▄███▄   
+ ██▓ ░ ▓██  ██▓ ░ ▓██ ▄▄ ▒▓█ ▄▄▀ █▓░ ░▓█ ▀
+ ▓▓░ ▒ ░▓▓  ▓▓░ ▒ ░▓▓ ▒▒ ░▒▓ ▒▒ ▓▓░▌▄▐░▓▓ 
+ ▓▒░ ▀ ░▒▓  ▓▒░ ▀ ░▒▓ ▀▀ ░░▒ ▀▀ ▓▒░ ▀ ░▒▓ 
+ ░░░ ▀ ░░░  ░░▒ ░ ░░░ ░░ ░░░ ░░▄▄▄▄ ▀ ░░░ 
+ ░░▒▀▀▀▒░░  ░▒▓ ▒ ▒░░ ▒▒ ▒░░ ▒▒▓▓▓▓ ░░░░▒ 
+ ░▒▒ ▓ ▒▒░  ▒▓▓ ▓ ▓▒░ ▓▓ ▒▒░ ▓▓▀▀▀▀ ▄ ▒▒▓ 
+ ▒▒▓ ▒ ▓▒▒  ▓▓█ █ ▓▓▒ ██ ▓▒▒ ██ ▓▓▓ ▓ ██▓ 
+ ▓▓█ ▄ █▓▓  ▓█▓ ▄ ▓█▓ ▄▄ █▓▓ ▄▄ █▓▓ ▄ ▓██ 
+ ▓██ ▒ ██▓  ███ ▒ ▓██ ▒▒ ██▓ ▒▒ ██▓ ▀ ███ 
+ ███ ▀ ███  ███▄ ▄███ ▀▀ ███ ▀▀▄ ███ ███ ▄
+ ███   ███  ▀███████▀  ███████    ▀███▀   
diff --git a/static/logo-ansi.txt b/static/logo-ansi.txt
new file mode 100644
index 0000000..992e446
--- /dev/null
+++ b/static/logo-ansi.txt
@@ -0,0 +1,12 @@
+[0;37;40m [0;95;40m███[0;37;40m   [0;95;40m███[0;37;40m  [0;95;40m███[0;37;40m   [0;95;40m███[0;37;40m  [0;95;40m█[0;95;45m██[0;95;40m██[0;37;40m      [0;95;40m▄███▄[0;37;40m   [0m
+[0;37;40m [0;95;40m██[0;95;45m▓[0;37;40m [0;36;40m░[0;37;40m [0;95;45m▓[0;95;40m██[0;37;40m  [0;95;40m██[0;95;45m▓[0;37;40m [0;36;40m░[0;37;40m [0;95;45m▓[0;95;40m██[0;37;40m [0;90;40m▄▄[0;37;40m [0;95;45m▒▓█[0;37;40m [0;90;40m▄▄▀[0;37;40m [0;95;40m█[0;95;45m▓░[0;37;40m [0;95;45m░▓[0;95;40m█[0;37;40m [0;90;40m▀[0m
+[0;37;40m [0;95;45m▓▓░[0;37;40m [0;36;40m▒[0;37;40m [0;95;45m░▓▓[0;37;40m  [0;95;45m▓▓░[0;37;40m [0;36;40m▒[0;37;40m [0;95;45m░▓▓[0;37;40m [0;36;40m▒▒[0;37;40m [0;95;45m░▒▓[0;37;40m [0;36;40m▒▒[0;37;40m [0;95;45m▓▓░[0;35;40m▌[0;90;40m▄[0;35;40m▐[0;95;45m░▓▓[0;37;40m [0m
+[0;37;40m [0;95;45m▓▒[0;96;46m░[0;37;40m [0;90;40m▀[0;37;40m [0;96;46m░[0;95;45m▒▓[0;37;40m  [0;95;45m▓▒[0;96;46m░[0;37;40m [0;90;40m▀[0;37;40m [0;96;46m░[0;95;45m▒▓[0;37;40m [0;90;40m▀▀[0;37;40m [0;96;46m░[0;95;45m░▒[0;37;40m [0;90;40m▀▀[0;37;40m [0;95;45m▓▒░[0;37;40m [0;90;40m▀[0;37;40m [0;96;46m░[0;95;45m▒▓[0;37;40m [0m
+[0;37;40m [0;95;45m░[0;96;46m░░[0;37;40m [0;34;40m▀[0;37;40m [0;96;46m░░[0;95;45m░[0;37;40m  [0;95;45m░[0;96;46m░▒[0;37;40m [0;34;40m░[0;37;40m [0;96;46m░░[0;95;45m░[0;37;40m [0;34;40m░░[0;37;40m [0;96;46m░░[0;95;45m░[0;37;40m [0;34;40m░░▄▄▄▄[0;37;40m [0;34;40m▀[0;37;40m [0;96;46m░░[0;95;45m░[0;37;40m [0m
+[0;37;40m [0;96;46m░░▒[0;96;40m▀▀▀[0;96;46m▒░░[0;37;40m  [0;96;46m░▒▓[0;37;40m [0;34;40m▒[0;37;40m [0;96;46m▒░░[0;37;40m [0;34;40m▒▒[0;37;40m [0;96;46m▒░░[0;37;40m [0;34;40m▒▒▓▓▓▓[0;37;40m [0;96;46m░░░░▒[0;37;40m [0m
+[0;37;40m [0;96;46m░▒▒[0;37;40m [0;34;40m▓[0;37;40m [0;96;46m▒▒░[0;37;40m  [0;96;46m▒▓▓[0;37;40m [0;34;40m▓[0;37;40m [0;96;46m▓▒░[0;37;40m [0;34;40m▓▓[0;37;40m [0;96;46m▒▒░[0;37;40m [0;34;40m▓▓▀▀▀▀[0;37;40m [0;34;40m▄[0;37;40m [0;96;46m▒▒▓[0;37;40m [0m
+[0;37;40m [0;96;46m▒▒▓[0;37;40m [0;34;40m▒[0;37;40m [0;96;46m▓▒▒[0;37;40m  [0;96;40m▓[0;96;46m▓[0;96;40m█[0;37;40m [0;34;40m█[0;37;40m [0;96;46m▓▓▒[0;37;40m [0;34;40m██[0;37;40m [0;96;46m▓▒▒[0;37;40m [0;34;40m██[0;37;40m [0;96;46m▓▓▓[0;37;40m [0;34;40m▓[0;37;40m [0;96;40m██▓[0;37;40m [0m
+[0;37;40m [0;96;46m▓▓█[0;37;40m [0;90;40m▄[0;37;40m [0;96;46m█▓▓[0;37;40m  [0;96;46m▓█▓[0;37;40m [0;90;40m▄[0;37;40m [0;96;46m▓█▓[0;37;40m [0;90;40m▄▄[0;37;40m [0;96;46m█▓▓[0;37;40m [0;90;40m▄▄[0;37;40m [0;96;40m█[0;96;46m▓▓[0;37;40m [0;90;40m▄[0;37;40m [0;96;46m▓██[0;37;40m [0m
+[0;37;40m [0;96;46m▓██[0;37;40m [0;36;40m▒[0;37;40m [0;96;46m██▓[0;37;40m  [0;96;46m███[0;37;40m [0;36;40m▒[0;37;40m [0;96;46m▓[0;96;40m██[0;37;40m [0;36;40m▒▒[0;37;40m [0;96;46m██▓[0;37;40m [0;36;40m▒▒[0;37;40m [0;96;40m██[0;96;46m▓[0;37;40m [0;90;40m▀[0;37;40m [0;96;46m███[0;37;40m [0m
+[0;37;40m [0;96;40m███[0;37;40m [0;90;40m▀[0;37;40m [0;96;40m███[0;37;40m  [0;96;40m█[0;96;46m██[0;96;40m▄[0;37;40m [0;96;40m▄[0;96;46m█[0;96;40m██[0;37;40m [0;90;40m▀▀[0;37;40m [0;96;46m███[0;37;40m [0;90;40m▀▀▄[0;37;40m [0;96;40m█[0;96;46m██[0;37;40m [0;96;46m███[0;37;40m [0;90;40m▄[0m
+[0;37;40m [0;96;46m███[0;37;40m   [0;96;46m███[0;37;40m  [0;96;40m▀██[0;96;46m███[0;96;40m██▀[0;37;40m  [0;96;40m███████[0;37;40m    [0;96;40m▀[0;96;46m███[0;96;40m▀[0;37;40m   [0m
diff --git a/tests/logo.rs b/tests/logo.rs
index 08884b5..21c3518 100644
--- a/tests/logo.rs
+++ b/tests/logo.rs
@@ -1,7 +1,9 @@
-//! Logo suite (m6, f0rge issues #15 + #27 + #28):
+//! Logo suite (m6, f0rge issues #15 + #26 + #27 + #28):
 //! `static/logo.svg` (lockup with hu13 as ) + `static/favicon.svg`
 //! (simplified mark-only 16px variant) exist + valid XML + <2KB total,
-//! wordmark variant, ASCII banner limits, `
` rendering invariant.
+//! wordmark variant, owner block-art banner (`static/ascii.txt` +
+//! `static/logo-ansi.txt`, 12 lines) + purity/strip tests + `
`
+//! 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).
@@ -209,34 +211,47 @@ fn wordmark_variant_exists_tiny() {
 
 #[test]
 fn ascii_banner_limits_and_aesthetic() {
+    // Owner block-art (#26): 12-line block glyphs reading "hu13".
+    // Charset is space + block set (█ ▓ ▒ ░ ▄ ▀ ▌ ▐) plus the legacy
+    // line-art chars (| - / _ +) for forward compat — still no letters,
+    // no `<>&"` so `
` stays verbatim via esc().
     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;
+    assert_eq!(lines.len(), 12, "owner banner is 12 lines");
+    let mut saw_block = 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!(!ch.is_ascii_alphabetic(), "no letters, got {ch:?}");
             assert!(
-                matches!(ch, '|' | '-' | '/' | '_' | '+' | ' '),
-                "sharp-corner aesthetic only (| - / _ + space), got {ch:?}"
+                !matches!(ch, '<' | '>' | '&' | '"'),
+                "nothing to escape, got {ch:?}"
+            );
+            saw_block |= matches!(ch, '█' | '▓' | '▒' | '░' | '▄' | '▀' | '▌' | '▐');
+            assert!(
+                matches!(
+                    ch,
+                    ' ' | '█'
+                        | '▓'
+                        | '▒'
+                        | '░'
+                        | '▄'
+                        | '▀'
+                        | '▌'
+                        | '▐'
+                        | '|'
+                        | '-'
+                        | '/'
+                        | '_'
+                        | '+'
+                ),
+                "block set + legacy (| - / _ + space), got {ch:?}"
             );
         }
     }
-    assert!(
-        saw_pipe && saw_hyphen && saw_slash,
-        "uses pipes+hyphens+slashes"
-    );
+    assert!(saw_block, "uses block glyphs");
+    assert!(art.contains('█'), "block fill present");
 }
 
 #[test]
@@ -250,3 +265,58 @@ fn ascii_renders_inside_pre_unescaped_except_esc() {
     assert!(pre.contains("
"), "inside 
");
     assert!(pre.contains(&art), "verbatim, unescaped-except-esc()");
 }
+
+/// Strip literal SGR codes (`[...m`, no ESC byte) the way the rubric
+/// prescribes: `sed 's/\[[0-9;]*m//g'`.
+fn strip_literal_sgr(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = String::with_capacity(s.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'[' {
+            let mut k = i + 1;
+            while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {
+                k += 1;
+            }
+            if k < bytes.len() && bytes[k] == b'm' && k > i + 1 {
+                i = k + 1;
+                continue;
+            }
+        }
+        let ch = s[i..].chars().next().expect("char boundary");
+        out.push(ch);
+        i += ch.len_utf8();
+    }
+    out
+}
+
+#[test]
+fn ansi_strips_to_ascii_byte_for_byte() {
+    // Owner ANSI original (#26) strips to the plain banner byte-for-byte.
+    let ansi = read_static("logo-ansi.txt");
+    let ascii = read_static("ascii.txt");
+    assert_eq!(strip_literal_sgr(&ansi), ascii, "strip(ansi) == ascii");
+    assert_eq!(ansi.lines().count(), 12, "ansi is 12 lines");
+    assert_eq!(ascii.lines().count(), 12, "ascii is 12 lines");
+}
+
+#[test]
+fn logo_ansi_is_esc_free_with_codes() {
+    // Stored with literal `[...m` codes, block glyphs, NO raw ESC bytes.
+    let ansi = read_static("logo-ansi.txt");
+    assert!(!ansi.contains('\u{1b}'), "no raw ESC bytes stored");
+    assert!(ansi.contains("[0;"), "holds SGR codes");
+    assert!(ansi.contains("[0m"), "holds reset");
+    assert!(ansi.contains('█'), "holds block glyphs");
+}
+
+#[test]
+fn static_holds_no_esc_bytes() {
+    // Success rubric (#26): `grep -r $'\\x1b' static/` is empty.
+    let dir = manifest().join("static");
+    for name in ["logo-ansi.txt", "ascii.txt", "logo.svg", "favicon.svg"] {
+        let bytes =
+            std::fs::read(dir.join(name)).unwrap_or_else(|_| panic!("static/{name} exists"));
+        assert!(!bytes.contains(&0x1b), "static/{name} holds no ESC bytes");
+    }
+}
-- 
2.55.0