#52 Brand + theme foundations: gl0w lockup, AA palette, CSS budget (#69 #70 #76)
Patch
gl0w.md+1 −1--- a/gl0w.md+++ b/gl0w.md@@ -13,7 +13,7 @@ f0rge ci run # local CI: fmt, clippy, test, dogfood1313 ```1414 1515 Dogfood (stub-tolerant in m1): `cargo run -- build <repo> -o /tmp/hu13-dogfood` must exit 0.16-CSS budget: `wc -c static/style.css` must stay under 15360 bytes (15KB);16+CSS budget: `wc -c static/style.css` must stay under 32768 bytes (32KB);1717 only allowed `border-radius` value is `0`: `grep border-radius static/style.css`.1818 1919 ## Inbox safety
src/build.rs+2 −0--- a/src/build.rs+++ b/src/build.rs@@ -873,6 +873,8 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {873873 let icon = include_str!("../static/favicon.svg");874874 std::fs::write(out.join("favicon.svg"), icon)875875 .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;876+ let social = include_bytes!("../static/og.png");877+ std::fs::write(out.join("og.png"), social).map_err(|e| format!("hu13: write og.png: {e}"))?;876878 let manifest = include_str!("../static/manifest.webmanifest");877879 std::fs::write(out.join("manifest.webmanifest"), manifest)878880 .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;
src/layout.rs+27 −15--- a/src/layout.rs+++ b/src/layout.rs@@ -100,7 +100,7 @@ pub fn page(title: &str, nav: &str, body: &str) -> String {100100 /// Head standards (m6 favicon, f0rge issue #16) ride along on every page:101101 /// SVG-first favicon (`rel=icon` + `apple-touch-icon` both point at102102 /// `/favicon.svg` — no binary blobs in the repo), light/dark103-/// `theme-color` (Tokyo Night `#d5d6db` / `#1a1b26`, matching103+/// `theme-color` (Tokyo Night `#f6f7f9` / `#1a1b26`, matching104104 /// `static/style.css`), `og:type=website`, and the `manifest.webmanifest`105105 /// link..106106 /// Which nav `href` is active for a page (m6 navbar, f0rge issue #18).@@ -203,7 +203,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin203203 }204204 }205205 format!(206- "<footer class=\"site-footer\"><div class=\"wrap\"><p class=\"muted footer-provenance\">{line}</p></div></footer>"206+ "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\">gl0w</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/inbox/issue\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>"207207 )208208 }209209 @@ -268,13 +268,19 @@ fn page_site_inner(268268 <link rel=\"stylesheet\" href=\"/style.css\">\n\269269 <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n\270270 <link rel=\"apple-touch-icon\" href=\"/favicon.svg\">\n\271-<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">\n\271+<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">\n\272272 <meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">\n\273273 <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n\274274 <link rel=\"alternate\" type=\"application/atom+xml\" href=\"/feed.atom\">\n\275275 <title>{title_e}</title>\n\276276 <meta property=\"og:title\" content=\"{title_e}\">\n\277-<meta property=\"og:type\" content=\"website\">\n"277+<meta property=\"og:type\" content=\"website\">\n\278+<meta property=\"og:site_name\" content=\"hu13 by gl0w\">\n\279+<meta property=\"og:image\" content=\"/og.png\">\n\280+<meta property=\"og:image:width\" content=\"1200\">\n\281+<meta property=\"og:image:height\" content=\"630\">\n\282+<meta property=\"og:image:alt\" content=\"hu13 — a gl0w project\">\n\283+<meta name=\"twitter:card\" content=\"summary_large_image\">\n"278284 );279285 if let Some(d) = description {280286 let d_e = esc(d);@@ -293,7 +299,7 @@ fn page_site_inner(293299 <html lang=\"en\">\n\294300 <head>\n{head}</head>\n\295301 <body>\n\296-<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"48\" height=\"48\"></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav></div></header>\n\302+<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\" aria-label=\"hu13 by gl0w — home\"><span class=\"gl0w-mark\" aria-hidden=\"true\">gl0w</span><span class=\"lockup-slash\" aria-hidden=\"true\">/</span><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"84\" height=\"28\"></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav></div></header>\n\297303 <main class=\"wrap\">\n{body}\n</main>\n\298304 {footer}\n\299305 </body>\n\@@ -357,7 +363,7 @@ mod tests {357363 for needle in [358364 "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",359365 "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",360- "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",366+ "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">",361367 "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",362368 "<meta property=\"og:title\"",363369 "<meta property=\"og:type\" content=\"website\">",@@ -612,21 +618,27 @@ mod tests {612618 css.contains("main>h1:first-child") && css.contains("margin-top:var(--gap-head)"),613619 "bare h1 shares nav->head gap"614620 );615- // #51: 48px logo (was 24px / 1.5rem), vector kept, header centers.621+ // #70: the 96x32 lockup keeps its 3:1 aspect instead of being622+ // squeezed into a 48x48 square (lettering was illegible).616623 assert!(css.contains(".site-logo"), "logo rule");617- assert!(css.contains("width:3rem"), "logo 48px width");618- assert!(css.contains("height:3rem"), "logo 48px height");619- assert!(!css.contains("width:1.5rem"), "old 24px gone");624+ assert!(625+ css.contains("width:auto;height:1.75rem"),626+ "logo keeps its aspect at 28px tall"627+ );628+ assert!(!css.contains("width:3rem"), "old square box gone");620629 let html = page("t", "<nav></nav>", "<p>b</p>");621630 assert!(622- html.contains("width=\"48\" height=\"48\""),623- "header img 48px attrs"631+ html.contains("width=\"84\" height=\"28\""),632+ "header img 3:1 attrs"624633 );625- // #52: footer provenance sits right, both themes (pure CSS var block).634+ // #70: footer is a left-aligned brand block (gl0w + hu13), not a635+ // lone right-aligned provenance line.626636 assert!(627- css.contains(".footer-provenance") && css.contains("text-align:right"),628- "footer right-aligned"637+ css.contains(".footer-provenance") && css.contains("text-align:left"),638+ "footer aligns with content"629639 );640+ assert!(css.contains(".footer-brand"), "footer brand row styled");641+ assert!(css.contains(".gl0w-mark"), "gl0w mark styled");630642 }631643 632644 #[test]
static/favicon.svg+3 −4--- a/static/favicon.svg+++ b/static/favicon.svg@@ -1,9 +1,8 @@11 <?xml version="1.0" encoding="UTF-8"?>2-<!-- hu13 favicon: simplified mark-only variant for 16px legibility. Full lettering lives in logo.svg. -->2+<!-- hu13 favicon: simplified mark-only variant for 16px legibility.3+ No background fill (the browser chrome supplies it). -->34 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="hu13 logo">45 <title>hu13</title>5- <rect x="0" y="0" width="32" height="32" fill="#1a1b26"/>6- <rect x="0.5" y="0.5" width="31" height="31" fill="none" stroke="#292e42"/>7- <polygon points="16,6 26,16 16,26 6,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>6+ <polygon points="16,5 27,16 16,27 5,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>87 <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>98 </svg>
static/logo.svg+10 −7--- a/static/logo.svg+++ b/static/logo.svg@@ -1,13 +1,16 @@11 <?xml version="1.0" encoding="UTF-8"?>2-<!-- hu13 lockup: diamond-hub mark + hu13 lettering as hand-drawn vectors. Reads at 24px header size. -->2+<!-- hu13 lockup: diamond-hub mark + hu13 lettering as hand-drawn vectors.3+ No background fill: the page supplies it, so the lockup works on both4+ the dark and light themes (ink flips via prefers-color-scheme). -->35 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32" width="96" height="32" role="img" aria-label="hu13 logo">46 <title>hu13</title>5- <rect x="0" y="0" width="96" height="32" fill="#1a1b26"/>6- <rect x="0.5" y="0.5" width="95" height="31" fill="none" stroke="#292e42"/>7+ <style>.ink{stroke:#c0caf5}@media (prefers-color-scheme:light){.ink{stroke:#343b58}}</style>78 <polygon points="16,4 28,16 16,28 4,16" fill="none" stroke="#7aa2f7" stroke-width="3"/>89 <rect x="13" y="13" width="6" height="6" fill="#7dcfff"/>9- <path d="M40 8 V24 M40 15 H46 V24" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>10- <path d="M52 8 V21 H58 V8" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>11- <path d="M64 10 L66 8 V24 M62 24 H70" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>12- <path d="M74 8 H80 V14 H74 M74 14 H80 V24 H74" fill="none" stroke="#c0caf5" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>10+ <g class="ink" fill="none" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">11+ <path d="M40 8 V24 M40 15 H46 V24"/>12+ <path d="M52 8 V21 H58 V8"/>13+ <path d="M64 10 L66 8 V24 M62 24 H70"/>14+ <path d="M74 8 H80 V14 H74 M74 14 H80 V24 H74"/>15+ </g>1316 </svg>
static/og.png+0 −0
static/style.css+68 −12--- a/static/style.css+++ b/static/style.css@@ -6,8 +6,11 @@66 --bg:#1a1b26;77 --bg-alt:#16161e;88 --bg-th:#1f2335;9+ --surface:#1f2335;10+ --surface-2:#24283b;911 --fg:#c0caf5;10- --muted:#565f89;12+ --fg-dim:#9aa5ce;13+ --muted:#9aa5ce;1114 --blue:#7aa2f7;1215 --cyan:#7dcfff;1316 --green:#9ece6a;@@ -16,6 +19,28 @@1619 --border:#292e42;1720 --visited:#9aa5ce;1821 --btn-fg:#16161e;22+ --state-open:#9ece6a;23+ --state-merged:#bb9af7;24+ --state-closed:#f7768e;25+ /* syntax tokens (build-time highlighter) */26+ --tok-kw:#bb9af7;27+ --tok-str:#9ece6a;28+ --tok-num:#ff9e64;29+ --tok-com:#8b93b8;30+ --tok-fn:#7aa2f7;31+ --tok-type:#2ac3de;32+ /* scale: one source for gaps, type, corners (f0rge #69) */33+ --s1:.25rem;34+ --s2:.5rem;35+ --s3:.75rem;36+ --s4:1rem;37+ --s5:1.5rem;38+ --s6:2rem;39+ --fs-sm:.85rem;40+ --fs-lg:1.15rem;41+ --fs-xl:1.5rem;42+ --fs-2xl:2rem;43+ --radius:0;1944 --gap-head:.75rem;2045 --gap-content:1rem;2146 --diff-add:#9ece6a;@@ -27,19 +52,31 @@2752 }2853 @media (prefers-color-scheme:light){2954 :root{30- --bg:#d5d6db;31- --bg-alt:#cbccd3;32- --bg-th:#b4b8c7;55+ --bg:#f6f7f9;56+ --bg-alt:#eceef2;57+ --bg-th:#e4e7ee;58+ --surface:#ffffff;59+ --surface-2:#eef0f4;3360 --fg:#343b58;34- --muted:#565f89;61+ --fg-dim:#4a5273;62+ --muted:#4a5273;3563 --blue:#34548a;3664 --cyan:#0f4b6e;3765 --green:#33635c;38- --yellow:#8c6c3e;66+ --yellow:#7d5f33;3967 --red:#8c4351;40- --border:#b4b8c7;68+ --border:#d6dae2;4169 --visited:#5a4a78;42- --btn-fg:#e9e9ed;70+ --btn-fg:#ffffff;71+ --state-open:#33635c;72+ --state-merged:#5a4a78;73+ --state-closed:#8c4351;74+ --tok-kw:#5a4a78;75+ --tok-str:#33635c;76+ --tok-num:#7d5f33;77+ --tok-com:#4a5273;78+ --tok-fn:#34548a;79+ --tok-type:#0f4b6e;4380 --diff-add:#33635c;4481 --diff-add-bg:#c6d4ca;4582 --diff-del:#8c4351;@@ -121,11 +158,26 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}121158 .btn-new:visited{color:var(--btn-fg)}122159 .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}123160 .muted{color:var(--muted)}124-footer.site-footer{border-top:1px solid var(--border)}125-footer.site-footer .wrap{padding:1rem}161+/* GFM markdown (f0rge #71): task lists, strikethrough, footnotes. */162+.task-list-item{list-style:none}163+.task-list-item input[type=checkbox]{margin:0 .4em 0 -1.2em;vertical-align:middle;accent-color:var(--green)}164+.prose del,.del{color:var(--fg-dim);text-decoration:line-through}165+.footnotes{margin-top:var(--s5);font-size:var(--fs-sm);color:var(--fg-dim)}166+.footnotes ol{padding-left:var(--s5)}167+.markdown table{background:var(--surface)}168+.markdown th{background:var(--bg-th);font-weight:600}169+.markdown td,.markdown th{padding:var(--s2) var(--s3)}170+.markdown tr:nth-child(even) td{background:var(--bg-alt)}171+footer.site-footer{border-top:1px solid var(--border);margin-top:var(--s6)}172+footer.site-footer .wrap{padding:var(--s4) 1rem var(--s5)}173+.footer-brand{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--s2) var(--s3);margin:0 0 var(--s2)}174+.footer-brand .gl0w-mark{text-decoration:none}175+.footer-tag{color:var(--fg);font-weight:600}176+.footer-links{margin:0 0 var(--s2);font-size:var(--fs-sm)}177+.footer-links a{margin-right:var(--s3)}126178 /* Provenance line (m6/footer-line, f0rge #41): the single muted line127179 inside the existing footer block — small, no extra whitespace. */128-footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem;text-align:right}180+footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align:left}129181 /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.130182 Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/131183 .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav@@ -134,7 +186,11 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem;text-align134186 .site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}135187 .brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}136188 .brand-lockup:visited{color:var(--fg)}137-.site-logo{display:block;width:3rem;height:3rem;flex:none}189+.gl0w-mark{font:600 .8rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em;190+ text-transform:lowercase;color:var(--cyan);border:1px solid var(--border);padding:.35rem .45rem}191+.brand-lockup:hover .gl0w-mark{color:var(--blue);border-color:var(--blue)}192+.lockup-slash{color:var(--border);font-weight:400}193+.site-logo{display:block;width:auto;height:1.75rem;flex:none}138194 .hu13-word{font-weight:700;color:var(--blue)}139195 .sep{flex:none;color:var(--muted)}140196 .site-header .site-nav.nav-scroll{flex:1 1 auto;min-width:0;background:none;border-bottom:none;overflow-x:auto;scroll-snap-type:none;-webkit-overflow-scrolling:touch}
tests/brand.rs+123 −0--- /dev/null+++ b/tests/brand.rs@@ -0,0 +1,123 @@1+//! f0rge #70: gl0w branding + gl0w.world attribution on every page,2+//! theme-aware logo, and an og:image social card.3+4+use std::path::{Path, PathBuf};5+use std::process::Command;6+7+fn fresh(prefix: &str) -> PathBuf {8+ let dir = std::env::temp_dir().join(format!(9+ "hu13-brand-{prefix}-{}-{}",10+ std::process::id(),11+ std::time::SystemTime::now()12+ .duration_since(std::time::UNIX_EPOCH)13+ .map(|d| d.as_nanos())14+ .unwrap_or(0)15+ ));16+ std::fs::create_dir_all(&dir).unwrap();17+ dir18+}19+20+fn git(repo: &Path, args: &[&str]) {21+ let st = Command::new("git")22+ .arg("-C")23+ .arg(repo)24+ .args(args)25+ .status()26+ .expect("spawn git");27+ assert!(st.success(), "git {args:?}");28+}29+30+fn build() -> PathBuf {31+ let repo = fresh("repo");32+ let out = fresh("out");33+ git(&repo, &["init", "-b", "main"]);34+ git(&repo, &["config", "user.name", "hu13-test"]);35+ git(&repo, &["config", "user.email", "[email protected]"]);36+ git(&repo, &["config", "commit.gpgsign", "false"]);37+ std::fs::write(repo.join("README.md"), "# brand\n").unwrap();38+ git(&repo, &["add", "-A"]);39+ git(&repo, &["commit", "-m", "brand"]);40+ let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))41+ .arg("build")42+ .arg(&repo)43+ .arg("-o")44+ .arg(&out)45+ .output()46+ .unwrap();47+ assert!(48+ produced.status.success(),49+ "{}",50+ String::from_utf8_lossy(&produced.stderr)51+ );52+ out53+}54+55+#[test]56+fn header_and_footer_carry_gl0w_branding() {57+ let out = build();58+ for page in ["index.html", "agents.html", "issues/index.html"] {59+ let html = std::fs::read_to_string(out.join(page)).expect(page);60+ assert!(61+ html.contains("<span class=\"gl0w-mark\""),62+ "{page}: gl0w mark in header"63+ );64+ assert!(65+ html.contains("hu13 — a gl0w project"),66+ "{page}: brand tagline"67+ );68+ assert!(69+ html.contains("href=\"https://gl0w.world\""),70+ "{page}: links to gl0w.world"71+ );72+ assert!(73+ html.contains("footer-provenance"),74+ "{page}: provenance kept"75+ );76+ // Header lockup keeps the hu13 mark and is linked home.77+ assert!(78+ html.contains("<img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\""),79+ "{page}: hu13 logo"80+ );81+ assert!(82+ html.contains("href=\"/\" aria-label=\"hu13 by gl0w"),83+ "{page}: lockup links home"84+ );85+ }86+}87+88+#[test]89+fn social_card_and_meta_present() {90+ let out = build();91+ let html = std::fs::read_to_string(out.join("index.html")).unwrap();92+ for needle in [93+ "<meta property=\"og:image\" content=\"/og.png\">",94+ "<meta property=\"og:image:width\" content=\"1200\">",95+ "<meta property=\"og:image:height\" content=\"630\">",96+ "<meta name=\"twitter:card\" content=\"summary_large_image\">",97+ "<meta property=\"og:site_name\" content=\"hu13 by gl0w\">",98+ ] {99+ assert!(html.contains(needle), "index.html has {needle}");100+ }101+ let png = std::fs::read(out.join("og.png")).expect("og.png emitted");102+ assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "real PNG");103+ assert!(png.len() > 1000, "not a stub");104+}105+106+#[test]107+fn logo_has_no_baked_background() {108+ let logo =109+ std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static/logo.svg"))110+ .unwrap();111+ assert!(!logo.contains("fill=\"#1a1b26\""), "no dark slab");112+ assert!(113+ logo.contains("@media (prefers-color-scheme:light)"),114+ "ink flips for light theme"115+ );116+ let css =117+ std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static/style.css"))118+ .unwrap();119+ assert!(120+ css.contains(".site-logo{display:block;width:auto;height:1.75rem"),121+ "logo keeps its 3:1 aspect (was squeezed into 48x48)"122+ );123+}
tests/favicon.rs+4 −3--- a/tests/favicon.rs+++ b/tests/favicon.rs@@ -80,7 +80,7 @@ fn build_fixture() -> (PathBuf, PathBuf) {8080 const HEAD_TAGS: &[&str] = &[8181 "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",8282 "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",83- "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#d5d6db\">",83+ "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">",8484 "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",8585 "<meta property=\"og:title\"",8686 "<meta property=\"og:type\" content=\"website\">",@@ -192,9 +192,10 @@ fn favicon_emitted_svg_only_no_binary_blobs() {192192 .extension()193193 .map(|e| e.to_string_lossy().to_ascii_lowercase())194194 .unwrap_or_default();195+ let is_social_card = p.file_name().is_some_and(|n| n == "og.png");195196 assert!(196- !["ico", "png", "icns"].contains(&ext.as_str()),197- "no binary icon blobs: {}",197+ !["ico", "png", "icns"].contains(&ext.as_str()) || is_social_card,198+ "no binary icon blobs (social card excepted): {}",198199 p.display()199200 );200201 }
tests/light_theme.rs+9 −6--- a/tests/light_theme.rs+++ b/tests/light_theme.rs@@ -126,8 +126,10 @@ fn light_media_block_overrides_vars() {126126 // Tokyo Night Light palette spot-checks inside the light block.127127 let inner = light_block_inner(&css);128128 for var in [129- "--bg:#d5d6db",129+ "--bg:#f6f7f9",130130 "--fg:#343b58",131+ "--muted:#4a5273",132+ "--surface:#ffffff",131133 "--blue:#34548a",132134 "--cyan:#0f4b6e",133135 "--red:#8c4351",@@ -162,9 +164,10 @@ fn no_hardcoded_palette_hex_outside_root() {162164 let css = without_root_blocks(&without_comments(&css()));163165 let palette = [164166 "#1a1b26", "#16161e", "#c0caf5", "#565f89", "#7aa2f7", "#7dcfff", "#9ece6a", "#e0af68",165- "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#d5d6db", "#343b58", "#34548a", "#0f4b6e",166- "#8c4351", "#33635c", "#cbccd3", "#b4b8c7", "#5a4a78", "#8c6c3e", "#e9e9ed", "#233328",167- "#38232e", "#c6d4ca", "#d9c2c7",167+ "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e",168+ "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328",169+ "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7",170+ "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e",168171 ];169172 for hex in palette {170173 assert!(@@ -217,7 +220,7 @@ fn light_budget_and_sharp_corners() {217220 let path = manifest().join("static").join("style.css");218221 let len = std::fs::metadata(&path).expect("style.css exists").len();219222 assert!(220- len < 15360,221- "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"223+ len < 32768,224+ "static/style.css is {len} bytes, budget is 32768 (see gl0w.md)"222225 );223226 }
tests/logo.rs+14 −2--- a/tests/logo.rs+++ b/tests/logo.rs@@ -137,9 +137,17 @@ fn logo_svg_exists_valid_tiny() {137137 "svg namespace present"138138 );139139 assert!(svg.contains("viewBox"), "scales via viewBox (16px + 32px)");140- for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {140+ for color in ["#7aa2f7", "#7dcfff", "#c0caf5"] {141141 assert!(svg.contains(color), "Tokyo Night palette keeps {color}");142142 }143+ assert!(144+ !svg.contains("fill=\"#1a1b26\""),145+ "logo has no baked-in background (light theme must not show a dark slab)"146+ );147+ assert!(148+ svg.contains("prefers-color-scheme:light"),149+ "lettering flips ink on the light theme"150+ );143151 assert_sharp_no_gradient("logo.svg", &svg);144152 // Logo-text (#27): hu13 lettering is hand-written <path>, never145153 // <text>/fonts, so the header lockup has no font dependency.@@ -182,9 +190,13 @@ fn favicon_variant_is_simplified_mark_only() {182190 "favicon is mark-only (no letter <path>s)"183191 );184192 assert_sharp_no_gradient("favicon.svg", &fav);185- for color in ["#1a1b26", "#7aa2f7", "#7dcfff"] {193+ for color in ["#7aa2f7", "#7dcfff"] {186194 assert!(fav.contains(color), "favicon keeps {color}");187195 }196+ assert!(197+ !fav.contains("fill=\"#1a1b26\""),198+ "favicon has no baked-in background"199+ );188200 let logo_len = std::fs::metadata(manifest().join("static").join("logo.svg"))189201 .expect("static/logo.svg exists")190202 .len();
tests/polish.rs+2 −2--- a/tests/polish.rs+++ b/tests/polish.rs@@ -129,8 +129,8 @@ fn css_budget_under_15k() {129129 let css = manifest().join("static").join("style.css");130130 let len = std::fs::metadata(&css).expect("style.css exists").len();131131 assert!(132- len < 15360,133- "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"132+ len < 32768,133+ "static/style.css is {len} bytes, budget is 32768 (see gl0w.md)"134134 );135135 }136136
tests/responsive.rs+2 −2--- a/tests/responsive.rs+++ b/tests/responsive.rs@@ -171,8 +171,8 @@ fn css_budget_and_sharp_corners() {171171 let path = manifest().join("static").join("style.css");172172 let len = std::fs::metadata(&path).expect("style.css exists").len();173173 assert!(174- len < 15360,175- "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"174+ len < 32768,175+ "static/style.css is {len} bytes, budget is 32768 (see gl0w.md)"176176 );177177 let css = css();178178 for line in css.lines() {
tests/theme_contrast.rs+157 −1--- /dev/null+++ b/tests/theme_contrast.rs@@ -0,0 +1,157 @@1+//! f0rge #76: every text color must pass WCAG AA (4.5:1) in both themes.2+//! Parsed from `static/style.css` so the palette cannot drift from the3+//! rule set without this test noticing.4+5+use std::path::PathBuf;6+7+fn css() -> String {8+ let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static/style.css");9+ std::fs::read_to_string(p).expect("style.css")10+}11+12+/// Body of a `{...}` block starting at `at` (brace-matched).13+fn block(css: &str, at: usize) -> &str {14+ let open = css[at..].find('{').expect("open brace") + at;15+ let bytes = css.as_bytes();16+ let mut depth = 0;17+ let mut i = open;18+ while i < bytes.len() {19+ match bytes[i] {20+ b'{' => depth += 1,21+ b'}' => {22+ depth -= 1;23+ if depth == 0 {24+ return &css[open + 1..i];25+ }26+ }27+ _ => {}28+ }29+ i += 1;30+ }31+ panic!("unclosed block");32+}33+34+fn var(block: &str, name: &str) -> String {35+ let needle = format!("{name}:");36+ let start = block37+ .find(&needle)38+ .unwrap_or_else(|| panic!("{name} missing"));39+ let rest = &block[start + needle.len()..];40+ let end = rest.find([';', '}']).unwrap_or(rest.len());41+ rest[..end].trim().to_string()42+}43+44+fn channel(c: f64) -> f64 {45+ if c <= 0.039_28 {46+ c / 12.9247+ } else {48+ ((c + 0.055) / 1.055).powf(2.4)49+ }50+}51+52+fn lum(hex: &str) -> f64 {53+ let h = hex.trim_start_matches('#');54+ let r = u8::from_str_radix(&h[0..2], 16).unwrap() as f64 / 255.0;55+ let g = u8::from_str_radix(&h[2..4], 16).unwrap() as f64 / 255.0;56+ let b = u8::from_str_radix(&h[4..6], 16).unwrap() as f64 / 255.0;57+ 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)58+}59+60+fn contrast(a: &str, b: &str) -> f64 {61+ let (hi, lo) = {62+ let (la, lb) = (lum(a), lum(b));63+ if la > lb {64+ (la, lb)65+ } else {66+ (lb, la)67+ }68+ };69+ (hi + 0.05) / (lo + 0.05)70+}71+72+fn check(theme: &str, block: &str, pairs: &[(&str, &str)]) {73+ for (fg, bg) in pairs {74+ let f = var(block, fg);75+ let b = var(block, bg);76+ let ratio = contrast(&f, &b);77+ assert!(78+ ratio >= 4.5,79+ "{theme}: {fg} {f} on {bg} {b} = {ratio:.2}:1 (AA needs 4.5)"80+ );81+ }82+}83+84+#[test]85+fn both_themes_pass_aa_for_text_colors() {86+ let css = css();87+ let dark_at = css.find(":root{").expect("dark root");88+ let dark = block(&css, dark_at);89+ let light_at = css90+ .find("@media (prefers-color-scheme:light)")91+ .expect("light block");92+ let light = block(&css, light_at);93+94+ let pairs = [95+ ("--fg", "--bg"),96+ ("--fg", "--bg-alt"),97+ ("--fg", "--surface"),98+ ("--fg", "--surface-2"),99+ ("--fg-dim", "--bg"),100+ ("--fg-dim", "--bg-alt"),101+ ("--fg-dim", "--bg-th"),102+ ("--muted", "--bg"),103+ ("--muted", "--bg-th"),104+ ("--blue", "--bg"),105+ ("--blue", "--surface"),106+ ("--cyan", "--bg"),107+ ("--green", "--bg"),108+ ("--red", "--bg"),109+ ("--yellow", "--bg"),110+ ("--state-open", "--bg"),111+ ("--state-merged", "--bg"),112+ ("--state-closed", "--bg"),113+ ("--tok-kw", "--surface"),114+ ("--tok-str", "--surface"),115+ ("--tok-num", "--surface"),116+ ("--tok-com", "--surface"),117+ ("--tok-com", "--surface-2"),118+ ("--tok-fn", "--surface"),119+ ("--tok-type", "--surface"),120+ ];121+ check("dark", dark, &pairs);122+ check("light", light, &pairs);123+}124+125+#[test]126+fn themes_define_the_same_vars() {127+ let css = css();128+ let dark = block(&css, css.find(":root{").expect("dark root"));129+ let light = block(130+ &css,131+ css.find("@media (prefers-color-scheme:light)")132+ .expect("light block"),133+ );134+ // Every dark color var has a light override (scale vars may be shared).135+ for line in dark.lines() {136+ let Some(name) = line137+ .trim()138+ .strip_prefix("--")139+ .and_then(|l| l.split(':').next())140+ else {141+ continue;142+ };143+ if name.starts_with("gap-")144+ || name.starts_with('s')145+ && name.len() <= 2146+ && name[1..].chars().all(|c| c.is_ascii_digit())147+ || name.starts_with("fs-")148+ || name == "radius"149+ {150+ continue;151+ }152+ assert!(153+ light.contains(&format!("--{name}:")),154+ "light theme must override --{name}"155+ );156+ }157+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.