From a2638027ffb8ace1dc5c4af6d5143c6a735d1cda Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 06:49:09 +0100 Subject: [PATCH] m6/light-theme: css vars + tokyo night light (131 tests) --- static/style.css | 113 +++++++++++++++------- tests/light_theme.rs | 223 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 37 deletions(-) create mode 100644 tests/light_theme.rs diff --git a/static/style.css b/static/style.css index 32f05a8..443fc6a 100644 --- a/static/style.css +++ b/static/style.css @@ -1,25 +1,67 @@ /* hu13 — Tokyo Night dark-first, hand-written. Sharp corners only. Zero JS. */ -/* Palette: Tokyo Night: bg #1a1b26, bg-alt #16161e, fg #c0caf5, - muted #565f89, blue #7aa2f7, cyan #7dcfff, green #9ece6a, - yellow #e0af68, red #f7768e, border #292e42. */ +/* Vars: :root = Tokyo Night dark; light overrides under prefers-color-scheme. */ *{box-sizing:border-box;border-radius:0} -:root{color-scheme:dark} -html{background:#1a1b26;-webkit-text-size-adjust:100%} -body{margin:0;background:#1a1b26;color:#c0caf5; +:root{ + color-scheme:dark light; + --bg:#1a1b26; + --bg-alt:#16161e; + --bg-th:#1f2335; + --fg:#c0caf5; + --muted:#565f89; + --blue:#7aa2f7; + --cyan:#7dcfff; + --green:#9ece6a; + --yellow:#e0af68; + --red:#f7768e; + --border:#292e42; + --visited:#9aa5ce; + --btn-fg:#16161e; + --diff-add:#9ece6a; + --diff-add-bg:#233328; + --diff-del:#f7768e; + --diff-del-bg:#38232e; + --diff-hunk:#7dcfff; + --diff-file:#c0caf5; +} +@media (prefers-color-scheme:light){ + :root{ + --bg:#d5d6db; + --bg-alt:#cbccd3; + --bg-th:#b4b8c7; + --fg:#343b58; + --muted:#565f89; + --blue:#34548a; + --cyan:#0f4b6e; + --green:#33635c; + --yellow:#8c6c3e; + --red:#8c4351; + --border:#b4b8c7; + --visited:#5a4a78; + --btn-fg:#e9e9ed; + --diff-add:#33635c; + --diff-add-bg:#c6d4ca; + --diff-del:#8c4351; + --diff-del-bg:#d9c2c7; + --diff-hunk:#0f4b6e; + --diff-file:#343b58; + } +} +html{background:var(--bg);-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--fg); font-family:system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,Cantarell,sans-serif; line-height:1.55;font-size:16px;overflow-x:clip} img,video,svg{max-width:100%;height:auto} /* Fluid container: base = mobile single-column, side padding, never fixed px. */ .wrap{max-width:100%;margin:0 auto;padding:0 1rem;min-width:0} -.site-header{background:#16161e;border-bottom:1px solid #292e42} +.site-header{background:var(--bg-alt);border-bottom:1px solid var(--border)} .site-header .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;padding:.75rem 1rem} -.brand{font-weight:700;color:#7aa2f7;overflow-wrap:anywhere} -.site-nav{background:#16161e;border-bottom:1px solid #292e42} +.brand{font-weight:700;color:var(--blue);overflow-wrap:anywhere} +.site-nav{background:var(--bg-alt);border-bottom:1px solid var(--border)} .site-nav .wrap{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 1rem} -.site-nav a{display:inline-flex;align-items:center;min-height:40px;color:#7dcfff;text-decoration:none;padding:.35rem .1rem} +.site-nav a{display:inline-flex;align-items:center;min-height:40px;color:var(--cyan);text-decoration:none;padding:.35rem .1rem} .site-nav a:hover,.site-nav a:focus{text-decoration:underline} -a{color:#7aa2f7} -a:visited{color:#9aa5ce} +a{color:var(--blue)} +a:visited{color:var(--visited)} main{padding:1.25rem 0 3rem;min-width:0} /* Repo header stacks on narrow; clone cmd + sha wrap safely. */ .repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0} @@ -27,20 +69,25 @@ main{padding:1.25rem 0 3rem;min-width:0} .clone,.repo-meta{margin:.25rem 0;min-width:0} .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all} code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - background:#16161e;color:#9ece6a;padding:.1em .3em;overflow-wrap:anywhere} -pre{background:#16161e;color:#c0caf5;border:1px solid #292e42; + background:var(--bg-alt);color:var(--green);padding:.1em .3em;overflow-wrap:anywhere} +pre{background:var(--bg-alt);color:var(--fg);border:1px solid var(--border); padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5;max-width:100%} pre code{background:none;padding:0;color:inherit;overflow-wrap:normal;word-break:normal} +/* Diff colors (line-level spans for #22; renderer lands there, vars live here). */ +.diff-add{color:var(--diff-add);background:var(--diff-add-bg)} +.diff-del{color:var(--diff-del);background:var(--diff-del-bg)} +.diff-hunk{color:var(--diff-hunk)} +.diff-file{color:var(--diff-file);font-weight:700} /* Scroll containers: tables never break layout; pre scrolls inside too. */ .table-scroll,.pre-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%} .table-scroll{margin:1rem 0} .pre-scroll{margin:1rem 0} .table-scroll table{margin:0} .pre-scroll pre{margin:0} -table{border-collapse:collapse;width:100%;margin:1rem 0;background:#16161e} -th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top;overflow-wrap:anywhere} -th{background:#1f2335;color:#7dcfff} -tr:nth-child(even) td{background:#1a1b26} +table{border-collapse:collapse;width:100%;margin:1rem 0;background:var(--bg-alt)} +th,td{text-align:left;padding:.5rem .75rem;border:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere} +th{background:var(--bg-th);color:var(--cyan)} +tr:nth-child(even) td{background:var(--bg)} td code,th code{word-break:break-all} /* List tables (m6/tables): long titles/shas wrap inside the scroll box; the # column stays narrow. */ td.title,td.sha{overflow-wrap:anywhere} @@ -51,20 +98,20 @@ td.num{width:1%;white-space:nowrap} /* Forms: full-width on mobile, >=40px touch targets. */ form{max-width:44rem;margin:1rem 0;min-width:0} label{display:block;margin:.75rem 0 .25rem;font-weight:600} -input[type=text],textarea{display:block;width:100%;max-width:100%;background:#16161e; - color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit;min-height:40px} -input[type=text]:focus,textarea:focus{outline:2px solid #7aa2f7;outline-offset:0} +input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt); + color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px} +input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0} textarea{min-height:12rem} button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center; - min-height:40px;min-width:40px;background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7; + min-height:40px;min-width:40px;background:var(--blue);color:var(--btn-fg);border:1px solid var(--blue); padding:.5rem 1rem;font:inherit;font-weight:700;cursor:pointer} -button:hover,input[type=submit]:hover{background:#7dcfff} -.muted{color:#565f89} -.badge{display:inline-block;border:1px solid #292e42;padding:.1em .5em;font-size:.85em} -.badge.pass{color:#9ece6a}.badge.fail{color:#f7768e} -.badge.running{color:#e0af68}.badge.cancelled{color:#565f89} -.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0;overflow-wrap:anywhere} -footer.site-footer{border-top:1px solid #292e42;color:#565f89} +button:hover,input[type=submit]:hover{background:var(--cyan)} +.muted{color:var(--muted)} +.badge{display:inline-block;border:1px solid var(--border);padding:.1em .5em;font-size:.85em} +.badge.pass{color:var(--green)}.badge.fail{color:var(--red)} +.badge.running{color:var(--yellow)}.badge.cancelled{color:var(--muted)} +.notice{border:1px solid var(--yellow);padding:.75rem 1rem;margin:1rem 0;overflow-wrap:anywhere} +footer.site-footer{border-top:1px solid var(--border);color:var(--muted)} footer.site-footer .wrap{padding:1rem} @media (min-width:768px){ .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)} @@ -74,11 +121,3 @@ footer.site-footer .wrap{padding:1rem} @media (min-width:1100px){ .wrap{max-width:72rem;padding:0 1.5rem} } -@media (prefers-color-scheme:light){ - html,body{background:#d5d6db;color:#343b58} - .site-header,.site-nav,code,kbd,samp,pre,table,input[type=text],textarea{background:#cbccd3} - pre,pre code{color:#343b58} - th{background:#b4b8c7;color:#0f4b6e} - tr:nth-child(even) td{background:#d5d6db} - a{color:#34548a} -} diff --git a/tests/light_theme.rs b/tests/light_theme.rs new file mode 100644 index 0000000..e403ef7 --- /dev/null +++ b/tests/light_theme.rs @@ -0,0 +1,223 @@ +//! Light theme (m6, f0rge issue #19): +//! CSS vars in `:root` (Tokyo Night dark) + light overrides under +//! `prefers-color-scheme`; `color-scheme: dark light`; diff vars for #22. +//! `theme-color` meta tags are owned by #16 (layout-level) — not asserted +//! here to keep this change to `style.css` + tests only. + +use std::path::PathBuf; + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn css() -> String { + std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css") +} + +/// Strip `/* ... */` comments so palette hex in prose is not mistaken +/// for hardcoded rule colors. +fn without_comments(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + } else { + out.push(bytes[i] as char); + i += 1; + } + } + out +} + +/// Remove every `:root { ... }` block (brace-matched) so var definitions +/// may carry palette hex; what remains must reference vars, not hex. +fn without_root_blocks(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if s[i..].starts_with(":root") { + if let Some(open) = s[i..].find('{') { + let mut depth = 0; + let mut j = i + open; + while j < bytes.len() { + if bytes[j] == b'{' { + depth += 1; + } else if bytes[j] == b'}' { + depth -= 1; + if depth == 0 { + break; + } + } + j += 1; + } + i = j + 1; + continue; + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +/// Inner text of the `@media (prefers-color-scheme:light)` block +/// (brace-matched), so later file content cannot pollute assertions. +fn light_block_inner(css: &str) -> &str { + let at = css + .find("@media (prefers-color-scheme:light)") + .expect("light block"); + let open = css[at..].find('{').expect("light {") + at; + let bytes = css.as_bytes(); + let mut depth = 0; + let mut j = open; + while j < bytes.len() { + if bytes[j] == b'{' { + depth += 1; + } else if bytes[j] == b'}' { + depth -= 1; + if depth == 0 { + break; + } + } + j += 1; + } + &css[open + 1..j] +} + +#[test] +fn color_scheme_dark_light() { + let css = css(); + assert!( + css.contains("color-scheme:dark light"), + ":root carries `color-scheme:dark light` so form controls follow the browser" + ); +} + +#[test] +fn dark_root_defines_vars() { + let css = css(); + for var in [ + "--bg:#1a1b26", + "--fg:#c0caf5", + "--blue:#7aa2f7", + "--cyan:#7dcfff", + "--green:#9ece6a", + "--red:#f7768e", + "--border:#292e42", + ] { + assert!(css.contains(var), ":root defines dark {var}"); + } +} + +#[test] +fn light_media_block_overrides_vars() { + let css = css(); + assert!( + css.contains("@media (prefers-color-scheme:light)"), + "light media block present (zero-JS OS switch)" + ); + // Tokyo Night Light palette spot-checks inside the light block. + let inner = light_block_inner(&css); + for var in [ + "--bg:#d5d6db", + "--fg:#343b58", + "--blue:#34548a", + "--cyan:#0f4b6e", + "--red:#8c4351", + "--green:#33635c", + ] { + assert!(inner.contains(var), "light overrides {var}"); + } +} + +#[test] +fn light_block_has_no_duplicated_rule_bodies() { + let css = css(); + let inner = light_block_inner(&css); + // Light block must only redefine vars: no component selectors re-opened. + for sel in [ + ".site-header", + ".site-nav", + "tr:nth-child", + "pre{", + "table{", + ] { + assert!( + !inner.contains(sel), + "light block must not duplicate rule bodies ({sel} found)" + ); + } + assert!(inner.contains(":root"), "light overrides live in :root"); +} + +#[test] +fn no_hardcoded_palette_hex_outside_root() { + let css = without_root_blocks(&without_comments(&css())); + let palette = [ + "#1a1b26", "#16161e", "#c0caf5", "#565f89", "#7aa2f7", "#7dcfff", "#9ece6a", "#e0af68", + "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#d5d6db", "#343b58", "#34548a", "#0f4b6e", + "#8c4351", "#33635c", "#cbccd3", "#b4b8c7", "#5a4a78", "#8c6c3e", "#e9e9ed", "#233328", + "#38232e", "#c6d4ca", "#d9c2c7", + ]; + for hex in palette { + assert!( + !css.to_lowercase().contains(hex), + "no hardcoded {hex} outside :root var definitions (use var(--...))" + ); + } +} + +#[test] +fn rules_reference_vars() { + let css = css(); + for needle in [ + "background:var(--bg", + "background:var(--bg-alt", + "color:var(--fg", + "color:var(--blue", + "color:var(--cyan", + "border:1px solid var(--border", + ] { + assert!(css.contains(needle), "rules reference {needle}"); + } +} + +#[test] +fn diff_vars_defined_dark_and_light() { + let css = css(); + // Dark (current values) + light (best Tokyo Light pick; #22 may adjust). + for var in [ + "--diff-add:#9ece6a", + "--diff-del:#f7768e", + "--diff-hunk:#7dcfff", + "--diff-add:#33635c", + "--diff-del:#8c4351", + "--diff-hunk:#0f4b6e", + ] { + assert!(css.contains(var), "diff var defined: {var}"); + } + for var in ["--diff-add-bg", "--diff-del-bg", "--diff-file"] { + assert!(css.contains(var), "diff var present: {var}"); + } + // Forward-compat classes for the #22 renderer. + for cls in [".diff-add", ".diff-del", ".diff-hunk", ".diff-file"] { + assert!(css.contains(cls), "diff class styled: {cls}"); + } +} + +#[test] +fn light_budget_and_sharp_corners() { + let path = manifest().join("static").join("style.css"); + let len = std::fs::metadata(&path).expect("style.css exists").len(); + assert!( + len < 15360, + "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)" + ); +} -- 2.55.0