1 From bf991339b9474f18e33f3684c79a2c2e6013e90e Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 08:23:50 +01004 Subject: [PATCH] m6/owner-logo: owner block-art ansi+plain, tty banner5 6 ---7 src/logo.rs | 126 ++++++++++++++++++++++++++++++++++++++++---8 src/main.rs | 6 +++9 static/ascii.txt | 17 ++++--10 static/logo-ansi.txt | 12 +++++11 tests/logo.rs | 110 ++++++++++++++++++++++++++++++-------12 5 files changed, 239 insertions(+), 32 deletions(-)13 create mode 100644 static/logo-ansi.txt14 15 diff --git a/src/logo.rs b/src/logo.rs16 index f580b2b..f71358c 10064417 --- a/src/logo.rs18 +++ b/src/logo.rs19 @@ -1,29 +1,44 @@20 -//! hu13 logo + ASCII banner (m6, f0rge issues #15 + #27 + #28).21 +//! hu13 logo + ASCII banner (m6, f0rge issues #15 + #26 + #27 + #28).22 //!23 //! The lockup lives hand-written in `static/logo.svg` (diamond-hub mark +24 //! hu13 lettered as <path>, no <text>/fonts; reads at 24px header size)25 //! plus `static/wordmark.svg` (legacy horizontal lockup); the simplified26 //! 16px favicon variant lives in `static/favicon.svg` (mark only, no27 //! letters — small sizes stay legible, full lettering stays in logo.svg);28 -//! the text banner lives in `static/ascii.txt`.29 +//! the text banner lives in `static/ascii.txt` (owner block-art, 12 lines,30 +//! stripped from `static/logo-ansi.txt`).31 //!32 -//! [`ASCII_ART`], [`LOGO_SVG`], [`FAVICON_SVG`] and [`WORDMARK_SVG`]33 -//! re-expose those static files for later issues (navbar #16, favicon #17,34 -//! wiki README #19, agents page) with no build step.35 +//! [`ASCII_ART`], [`LOGO_ANSI_RAW`], [`LOGO_SVG`], [`FAVICON_SVG`] and36 +//! [`WORDMARK_SVG`] re-expose those static files for later issues (navbar37 +//! #16, favicon #17, wiki README #19, agents page) with no build step.38 //!39 //! [`ascii_pre`] renders the banner as an escaped `<pre>` block (see40 -//! [`crate::layout::esc`]): the art itself is plain `|/-_` plus spaces so41 +//! [`crate::layout::esc`]): the art itself is block glyphs plus spaces so42 //! escaping is a no-op today, but the wrapper keeps the invariant that any43 //! future art containing `<>&"` stays inert inside HTML.44 +//!45 +//! `static/logo-ansi.txt` stores the owner's 12-line ANSI art with literal46 +//! `[...m` codes and NO raw ESC bytes (repo stays ESC-free); terminal use47 +//! goes through [`logo_ansi_for_tty`], which prefixes each SGR code with48 +//! ESC at runtime so a tty renders colors while pipes stay plain.49 50 use crate::layout::esc;51 52 // TODO(m6+): stubs only — navbar (#16), favicon (#17), wiki README (#19)53 // and the agents page will call these; allow until wired.54 #[allow(dead_code)]55 -/// ASCII banner, byte-for-byte `static/ascii.txt` (5 lines, <=60 cols).56 +/// ASCII banner, byte-for-byte `static/ascii.txt` (owner block-art,57 +/// 12 lines, 42 cols, block glyphs + spaces).58 pub const ASCII_ART: &str = include_str!("../static/ascii.txt");59 60 +#[allow(dead_code)]61 +/// Owner ANSI block-art, byte-for-byte `static/logo-ansi.txt` (12 lines).62 +///63 +/// Stored with literal `[...m` SGR codes and NO raw ESC bytes (see raw64 +/// file); use [`logo_ansi_for_tty`] for terminal output and65 +/// [`strip_ansi_sgr`] for the plain render.66 +pub const LOGO_ANSI_RAW: &str = include_str!("../static/logo-ansi.txt");67 +68 #[allow(dead_code)]69 /// Header lockup, byte-for-byte `static/logo.svg` (mark + hu13 as <path>).70 pub const LOGO_SVG: &str = include_str!("../static/logo.svg");71 @@ -50,6 +65,80 @@ pub fn ascii_pre() -> String {72 )73 }74 75 +/// Strip SGR color codes from owner ANSI art.76 +///77 +/// Removes literal `[...m` sequences (`[` + digits/`;` + `m`) plus the same78 +/// sequences with a leading ESC byte (`\x1b[...m`, as produced by79 +/// [`interpret_ansi_sgr`]), leaving the plain block glyphs. Equivalent to80 +/// `sed 's/\[[0-9;]*m//g'` on the ESC-free stored file.81 +#[allow(dead_code)]82 +#[must_use]83 +pub fn strip_ansi_sgr(s: &str) -> String {84 + let bytes = s.as_bytes();85 + let mut out = String::with_capacity(s.len());86 + let mut i = 0;87 + while i < bytes.len() {88 + let mut j = i;89 + // Optional single ESC prefix (interpreted form).90 + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'[' {91 + j += 1;92 + }93 + if bytes[j] == b'[' {94 + let mut k = j + 1;95 + while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {96 + k += 1;97 + }98 + if k < bytes.len() && bytes[k] == b'm' && k > j + 1 {99 + i = k + 1;100 + continue;101 + }102 + }103 + let ch = s[i..].chars().next().expect("char boundary");104 + out.push(ch);105 + i += ch.len_utf8();106 + }107 + out108 +}109 +110 +/// Prefix each literal SGR code (`[...m`) with ESC for tty output.111 +///112 +/// The stored file is ESC-free so the repo never holds raw ESC bytes;113 +/// this converts at runtime so a terminal renders magenta/cyan/grey.114 +/// A `[` not followed by digits/`;` + `m` is left alone.115 +#[allow(dead_code)]116 +#[must_use]117 +pub fn interpret_ansi_sgr(s: &str) -> String {118 + let bytes = s.as_bytes();119 + let mut out = String::with_capacity(s.len() + 64);120 + let mut i = 0;121 + while i < bytes.len() {122 + if bytes[i] == b'[' {123 + let mut k = i + 1;124 + while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {125 + k += 1;126 + }127 + if k < bytes.len() && bytes[k] == b'm' && k > i + 1 {128 + out.push('\u{1b}');129 + let ch = s[i..].chars().next().expect("char boundary");130 + out.push(ch);131 + i += ch.len_utf8();132 + continue;133 + }134 + }135 + let ch = s[i..].chars().next().expect("char boundary");136 + out.push(ch);137 + i += ch.len_utf8();138 + }139 + out140 +}141 +142 +/// Owner ANSI art interpreted for a tty (ESC-prefixed SGR codes).143 +#[allow(dead_code)]144 +#[must_use]145 +pub fn logo_ansi_for_tty() -> String {146 + interpret_ansi_sgr(LOGO_ANSI_RAW)147 +}148 +149 #[cfg(test)]150 mod tests {151 use super::*;152 @@ -80,6 +169,29 @@ mod tests {153 assert!(!pre.contains("<script"), "no script smuggling");154 }155 156 + #[test]157 + fn ansi_raw_is_esc_free_with_codes() {158 + // Owner file (#26): literal `[...m` codes, block glyphs, no ESC.159 + assert!(!LOGO_ANSI_RAW.contains('\u{1b}'), "no raw ESC bytes stored");160 + assert!(LOGO_ANSI_RAW.contains("[0;"), "holds SGR codes");161 + assert!(LOGO_ANSI_RAW.contains('█'), "holds block glyphs");162 + assert_eq!(LOGO_ANSI_RAW.lines().count(), 12, "12 lines");163 + }164 +165 + #[test]166 + fn strip_yields_ascii() {167 + assert_eq!(strip_ansi_sgr(LOGO_ANSI_RAW), ASCII_ART, "strip == ascii");168 + assert_eq!(strip_ansi_sgr(ASCII_ART), ASCII_ART, "plain is stable");169 + }170 +171 + #[test]172 + fn tty_interpretation_holds_esc_and_strips_back() {173 + let tty = logo_ansi_for_tty();174 + assert!(tty.contains('\u{1b}'), "tty form carries ESC");175 + assert!(tty.contains('█'), "glyphs kept");176 + assert_eq!(strip_ansi_sgr(&tty), ASCII_ART, "tty strips to ascii");177 + }178 +179 #[test]180 fn svg_consts_match_tiny_marks() {181 for (name, svg) in [182 diff --git a/src/main.rs b/src/main.rs183 index bcacf00..cffc87e 100644184 --- a/src/main.rs185 +++ b/src/main.rs186 @@ -464,6 +464,12 @@ fn real_main() -> i32 {187 match rest[0].as_str() {188 "--version" | "-V" | "version" => {189 println!("hu13 {VERSION}");190 + // Owner logo (#26): on a tty also show the ANSI block-art191 + // interpreted (ESC-prefixed so colors render); pipes stay a192 + // single plain version line. Zero-dep via IsTerminal.193 + if std::io::IsTerminal::is_terminal(&std::io::stdout()) {194 + print!("{}", crate::logo::logo_ansi_for_tty());195 + }196 0197 }198 "--help" | "-h" | "help" => {199 diff --git a/static/ascii.txt b/static/ascii.txt200 index 1e39422..020ad10 100644201 --- a/static/ascii.txt202 +++ b/static/ascii.txt203 @@ -1,5 +1,12 @@204 -| /| ___205 -|___ | | |206 -| | | | | |---207 -| | | | | | |208 -| | |___| __|_ |___|209 + ███ ███ ███ ███ █████ ▄███▄ 210 + ██▓ ░ ▓██ ██▓ ░ ▓██ ▄▄ ▒▓█ ▄▄▀ █▓░ ░▓█ ▀211 + ▓▓░ ▒ ░▓▓ ▓▓░ ▒ ░▓▓ ▒▒ ░▒▓ ▒▒ ▓▓░▌▄▐░▓▓ 212 + ▓▒░ ▀ ░▒▓ ▓▒░ ▀ ░▒▓ ▀▀ ░░▒ ▀▀ ▓▒░ ▀ ░▒▓ 213 + ░░░ ▀ ░░░ ░░▒ ░ ░░░ ░░ ░░░ ░░▄▄▄▄ ▀ ░░░ 214 + ░░▒▀▀▀▒░░ ░▒▓ ▒ ▒░░ ▒▒ ▒░░ ▒▒▓▓▓▓ ░░░░▒ 215 + ░▒▒ ▓ ▒▒░ ▒▓▓ ▓ ▓▒░ ▓▓ ▒▒░ ▓▓▀▀▀▀ ▄ ▒▒▓ 216 + ▒▒▓ ▒ ▓▒▒ ▓▓█ █ ▓▓▒ ██ ▓▒▒ ██ ▓▓▓ ▓ ██▓ 217 + ▓▓█ ▄ █▓▓ ▓█▓ ▄ ▓█▓ ▄▄ █▓▓ ▄▄ █▓▓ ▄ ▓██ 218 + ▓██ ▒ ██▓ ███ ▒ ▓██ ▒▒ ██▓ ▒▒ ██▓ ▀ ███ 219 + ███ ▀ ███ ███▄ ▄███ ▀▀ ███ ▀▀▄ ███ ███ ▄220 + ███ ███ ▀███████▀ ███████ ▀███▀ 221 diff --git a/static/logo-ansi.txt b/static/logo-ansi.txt222 new file mode 100644223 index 0000000..992e446224 --- /dev/null225 +++ b/static/logo-ansi.txt226 @@ -0,0 +1,12 @@227 +[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 [0m228 +[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▀[0m229 +[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 [0m230 +[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 [0m231 +[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 [0m232 +[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 [0m233 +[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 [0m234 +[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 [0m235 +[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 [0m236 +[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 [0m237 +[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▄[0m238 +[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 [0m239 diff --git a/tests/logo.rs b/tests/logo.rs240 index 08884b5..21c3518 100644241 --- a/tests/logo.rs242 +++ b/tests/logo.rs243 @@ -1,7 +1,9 @@244 -//! Logo suite (m6, f0rge issues #15 + #27 + #28):245 +//! Logo suite (m6, f0rge issues #15 + #26 + #27 + #28):246 //! `static/logo.svg` (lockup with hu13 as <path>) + `static/favicon.svg`247 //! (simplified mark-only 16px variant) exist + valid XML + <2KB total,248 -//! wordmark variant, ASCII banner limits, `<pre>` rendering invariant.249 +//! wordmark variant, owner block-art banner (`static/ascii.txt` +250 +//! `static/logo-ansi.txt`, 12 lines) + purity/strip tests + `<pre>`251 +//! rendering invariant.252 //!253 //! File-level suite (hu13 is a binary crate, so `src/logo.rs` unit tests254 //! cover the `ascii_pre()` helper itself; here we assert on disk).255 @@ -209,34 +211,47 @@ fn wordmark_variant_exists_tiny() {256 257 #[test]258 fn ascii_banner_limits_and_aesthetic() {259 + // Owner block-art (#26): 12-line block glyphs reading "hu13".260 + // Charset is space + block set (█ ▓ ▒ ░ ▄ ▀ ▌ ▐) plus the legacy261 + // line-art chars (| - / _ +) for forward compat — still no letters,262 + // no `<>&"` so `<pre>` stays verbatim via esc().263 let art = read_static("ascii.txt");264 assert!(!art.trim().is_empty(), "banner is non-empty");265 let lines: Vec<&str> = art.lines().collect();266 - assert!(267 - lines.len() <= 12,268 - "banner is {} lines, cap is 12",269 - lines.len()270 - );271 - assert!(lines.len() >= 2, "banner is a banner, not one line");272 - let mut saw_pipe = false;273 - let mut saw_hyphen = false;274 - let mut saw_slash = false;275 + assert_eq!(lines.len(), 12, "owner banner is 12 lines");276 + let mut saw_block = false;277 for line in &lines {278 assert!(line.chars().count() <= 60, "banner line too wide: {line:?}");279 for ch in line.chars() {280 - saw_pipe |= ch == '|';281 - saw_hyphen |= ch == '-';282 - saw_slash |= ch == '/';283 + assert!(!ch.is_ascii_alphabetic(), "no letters, got {ch:?}");284 assert!(285 - matches!(ch, '|' | '-' | '/' | '_' | '+' | ' '),286 - "sharp-corner aesthetic only (| - / _ + space), got {ch:?}"287 + !matches!(ch, '<' | '>' | '&' | '"'),288 + "nothing to escape, got {ch:?}"289 + );290 + saw_block |= matches!(ch, '█' | '▓' | '▒' | '░' | '▄' | '▀' | '▌' | '▐');291 + assert!(292 + matches!(293 + ch,294 + ' ' | '█'295 + | '▓'296 + | '▒'297 + | '░'298 + | '▄'299 + | '▀'300 + | '▌'301 + | '▐'302 + | '|'303 + | '-'304 + | '/'305 + | '_'306 + | '+'307 + ),308 + "block set + legacy (| - / _ + space), got {ch:?}"309 );310 }311 }312 - assert!(313 - saw_pipe && saw_hyphen && saw_slash,314 - "uses pipes+hyphens+slashes"315 - );316 + assert!(saw_block, "uses block glyphs");317 + assert!(art.contains('█'), "block fill present");318 }319 320 #[test]321 @@ -250,3 +265,58 @@ fn ascii_renders_inside_pre_unescaped_except_esc() {322 assert!(pre.contains("<pre>"), "inside <pre>");323 assert!(pre.contains(&art), "verbatim, unescaped-except-esc()");324 }325 +326 +/// Strip literal SGR codes (`[...m`, no ESC byte) the way the rubric327 +/// prescribes: `sed 's/\[[0-9;]*m//g'`.328 +fn strip_literal_sgr(s: &str) -> String {329 + let bytes = s.as_bytes();330 + let mut out = String::with_capacity(s.len());331 + let mut i = 0;332 + while i < bytes.len() {333 + if bytes[i] == b'[' {334 + let mut k = i + 1;335 + while k < bytes.len() && (bytes[k].is_ascii_digit() || bytes[k] == b';') {336 + k += 1;337 + }338 + if k < bytes.len() && bytes[k] == b'm' && k > i + 1 {339 + i = k + 1;340 + continue;341 + }342 + }343 + let ch = s[i..].chars().next().expect("char boundary");344 + out.push(ch);345 + i += ch.len_utf8();346 + }347 + out348 +}349 +350 +#[test]351 +fn ansi_strips_to_ascii_byte_for_byte() {352 + // Owner ANSI original (#26) strips to the plain banner byte-for-byte.353 + let ansi = read_static("logo-ansi.txt");354 + let ascii = read_static("ascii.txt");355 + assert_eq!(strip_literal_sgr(&ansi), ascii, "strip(ansi) == ascii");356 + assert_eq!(ansi.lines().count(), 12, "ansi is 12 lines");357 + assert_eq!(ascii.lines().count(), 12, "ascii is 12 lines");358 +}359 +360 +#[test]361 +fn logo_ansi_is_esc_free_with_codes() {362 + // Stored with literal `[...m` codes, block glyphs, NO raw ESC bytes.363 + let ansi = read_static("logo-ansi.txt");364 + assert!(!ansi.contains('\u{1b}'), "no raw ESC bytes stored");365 + assert!(ansi.contains("[0;"), "holds SGR codes");366 + assert!(ansi.contains("[0m"), "holds reset");367 + assert!(ansi.contains('█'), "holds block glyphs");368 +}369 +370 +#[test]371 +fn static_holds_no_esc_bytes() {372 + // Success rubric (#26): `grep -r $'\\x1b' static/` is empty.373 + let dir = manifest().join("static");374 + for name in ["logo-ansi.txt", "ascii.txt", "logo.svg", "favicon.svg"] {375 + let bytes =376 + std::fs::read(dir.join(name)).unwrap_or_else(|_| panic!("static/{name} exists"));377 + assert!(!bytes.contains(&0x1b), "static/{name} holds no ESC bytes");378 + }379 +}380 -- 381 2.55.0382