brand + theme foundations: gl0w lockup, AA palette, CSS budget (#69 #70 #76)

c602a4bgl0w

all commits

Message

brand + theme foundations: gl0w lockup, AA palette, CSS budget (#69 #70 #76)

#69: budget 15360 -> 32768 (CSS is 17.4KB with the new work) in the
three tests and gl0w.md; add the token set the skin issues write
against (--surface/-2, --fg-dim, spacing s1..s6, type scale,
state colors, --radius) and document it at the top of the file.

#70: header is now gl0w mark / hu13 lockup (96x32 logo keeps its
aspect at 28px instead of being squeezed to 48x16); footer is a
left-aligned brand block with 'hu13 — a gl0w project', a
gl0w.world link and the provenance line; og:image (+width/height/
alt, twitter card, site_name) points at a generated 1200x630 PNG;
logo.svg/favicon.svg lose the baked #1a1b26 slab and flip ink via
prefers-color-scheme:light.

#76: light palette is a real light surface set (#f6f7f9 page,
#eceef2 header, #ffffff panels, #d6dae2 borders); --muted is now
AA in both themes (#9aa5ce dark, #4a5273 light) and every text var
is checked by tests/theme_contrast.rs (computed WCAG, both themes).
Adds GFM table/task-list/del/footnote styles for #71.

Diff

gl0w.md+1 −1
diff --git a/gl0w.md b/gl0w.mdindex 9088ce8..0cb56f7 100644--- 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
diff --git a/src/build.rs b/src/build.rsindex a88adba..a49f34e 100644--- 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
diff --git a/src/layout.rs b/src/layout.rsindex 70482a2..d985a04 100644--- 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
diff --git a/static/favicon.svg b/static/favicon.svgindex 098e978..d1572be 100644--- 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
diff --git a/static/logo.svg b/static/logo.svgindex 1e7b01c..63ed209 100644--- 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
diff --git a/static/og.png b/static/og.pngnew file mode 100644index 0000000..d7be379Binary files /dev/null and b/static/og.png differ
static/style.css+68 −12
diff --git a/static/style.css b/static/style.cssindex a75e481..d764077 100644--- 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
diff --git a/tests/brand.rs b/tests/brand.rsnew file mode 100644index 0000000..d81c023--- /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
diff --git a/tests/favicon.rs b/tests/favicon.rsindex 61c916e..cb85086 100644--- 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
diff --git a/tests/light_theme.rs b/tests/light_theme.rsindex e403ef7..d7cb27c 100644--- 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
diff --git a/tests/logo.rs b/tests/logo.rsindex 21c3518..12537d0 100644--- 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
diff --git a/tests/polish.rs b/tests/polish.rsindex 6e95324..ea48bc3 100644--- 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
diff --git a/tests/responsive.rs b/tests/responsive.rsindex 1cf554a..bb72480 100644--- 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 −0
diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rsnew file mode 100644index 0000000..9ce311f--- /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+}