#13 m6/responsive
Patch
src/build.rs+14 −11--- a/src/build.rs+++ b/src/build.rs@@ -135,9 +135,10 @@ fn href_esc(path: &str) -> String {135135 esc(path)136136 }137137 138-/// Render one text file body: escaped `<pre>` with line numbers.138+/// Render one text file body: escaped `<pre>` with line numbers, wrapped in139+/// a horizontal-scroll container so long lines never break the 360px layout.139140 fn render_text_pre(display: &str) -> String {140- let mut out = String::from("<pre class=\"code\">\n");141+ let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");141142 if display.is_empty() {142143 out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");143144 } else {@@ -149,7 +150,7 @@ fn render_text_pre(display: &str) -> String {149150 out.push_str("</span>\n");150151 }151152 }152- out.push_str("</pre>\n");153+ out.push_str("</pre></div>\n");153154 out154155 }155156 @@ -381,20 +382,21 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu381382 .map(|m| m.len())382383 .unwrap_or(0);383384 384- // index.html header.385- let mut body = format!("<h1>{}</h1>\n", esc(&name));385+ // index.html header: stacked repo-head (name + clone + meta) so narrow386+ // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.387+ let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));386388 match &description {387389 Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),388390 None => body.push_str("<p class=\"muted\">No description.</p>\n"),389391 }390392 if let Some(url) = &clone_url {391393 body.push_str(&format!(392- "<p>Clone: <code>git clone {}</code></p>\n",394+ "<p class=\"clone\">Clone: <code>git clone {}</code></p>\n",393395 esc(url)394396 ));395397 }396398 body.push_str(&format!(397- "<p>Branch <code>{}</code> @ <code>{}</code></p>\n",399+ "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",398400 esc(&branch),399401 esc(&short_sha)400402 ));@@ -417,7 +419,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu417419 <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"418420 ));419421 420- body.push_str("<h2>Files</h2>\n<ul class=\"tree\">\n");422+ body.push_str("</div>\n");423+ // Single column on mobile; tree+readme sit side by side only at >=768px.424+ body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n<ul class=\"tree\">\n");421425 for e in dir_entries.get("").cloned().unwrap_or_default() {422426 if e.is_dir {423427 body.push_str(&format!(@@ -434,9 +438,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu434438 ));435439 }436440 }437- body.push_str("</ul>\n");438-439- body.push_str("<h2>README</h2>\n");441+ body.push_str("</ul>\n</section>\n<section><h2>README</h2>\n");440442 match &readme {441443 Some((path, blob)) => {442444 body.push_str(&format!(@@ -451,6 +453,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu451453 }452454 None => body.push_str("<p class=\"muted\">No README found.</p>\n"),453455 }456+ body.push_str("</section></div>\n");454457 455458 let index = crate::layout::page(&name, &nav, &body);456459 std::fs::write(out.join("index.html"), index)
src/commits.rs+3 −3--- a/src/commits.rs+++ b/src/commits.rs@@ -35,7 +35,7 @@ pub fn render_loaded(3535 if log.is_empty() {3636 body.push_str("<p class=\"muted\">No commits yet.</p>\n");3737 } else {38- body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");38+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");3939 for c in log {4040 body.push_str(&format!(4141 "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",@@ -46,7 +46,7 @@ pub fn render_loaded(4646 esc(&c.date),4747 ));4848 }49- body.push_str("</tbody>\n</table>\n");49+ body.push_str("</tbody>\n</table></div>\n");5050 }5151 let html = page(&format!("commits — {site_name}"), nav, &body);5252 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;@@ -59,7 +59,7 @@ pub fn render_loaded(5959 let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;6060 let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;6161 let detail = format!(62- "<h1>commit {}</h1>\n<p><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<pre>{}</pre>\n<h2>Diff</h2>\n<pre class=\"diff\">{}</pre>\n",62+ "<h1>commit {}</h1>\n<p class=\"repo-meta\"><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{}</pre></div>\n<h2>Diff</h2>\n<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",6363 esc(short_sha(&c.sha)),6464 esc(&c.sha),6565 esc(&c.author),
src/forge.rs+18 −9--- a/src/forge.rs+++ b/src/forge.rs@@ -889,7 +889,10 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {889889 body.push_str("<h2>Patch</h2>\n");890890 match &pr.patch {891891 Some(patch) if !patch.is_empty() => {892- body.push_str(&format!("<pre class=\"diff\">{}</pre>\n", esc(patch)));892+ body.push_str(&format!(893+ "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",894+ esc(patch)895+ ));893896 }894897 _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),895898 }@@ -934,7 +937,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S934937 if data.issues.is_empty() {935938 body.push_str("<p class=\"muted\">No issues yet.</p>\n");936939 } else {937- body.push_str("<table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");940+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");938941 for issue in &data.issues {939942 body.push_str(&format!(940943 "<tr><td>{n}</td><td><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",@@ -946,7 +949,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S946949 created = esc(&issue.created),947950 ));948951 }949- body.push_str("</tbody>\n</table>\n");952+ body.push_str("</tbody>\n</table></div>\n");950953 }951954 if !data.labels.is_empty() {952955 body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");@@ -974,7 +977,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {974977 if prs.is_empty() {975978 body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");976979 } else {977- body.push_str("<table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");980+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");978981 for pr in prs {979982 let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));980983 let draft = if pr.draft { " draft" } else { "" };@@ -987,7 +990,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {987990 created = esc(&pr.created),988991 ));989992 }990- body.push_str("</tbody>\n</table>\n");993+ body.push_str("</tbody>\n</table></div>\n");991994 }992995 page(&format!("pulls — {site_name}"), nav, &body)993996 }@@ -1053,7 +1056,7 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {10531056 if run.steps.is_empty() {10541057 body.push_str("<p class=\"muted\">No step results recorded.</p>\n");10551058 } else {1056- body.push_str("<table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");1059+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");10571060 for s in &run.steps {10581061 body.push_str(&format!(10591062 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",@@ -1062,17 +1065,23 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {10621065 s.duration_ms,10631066 ));10641067 }1065- body.push_str("</tbody>\n</table>\n");1068+ body.push_str("</tbody>\n</table></div>\n");10661069 }10671070 body.push_str("<h3>Log</h3>\n");10681071 if run.body.trim().is_empty() {10691072 body.push_str("<p class=\"muted\">Empty log.</p>\n");10701073 } else {1071- body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(&run.body)));1074+ body.push_str(&format!(1075+ "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1076+ esc(&run.body)1077+ ));10721078 }10731079 for (name, content) in &run.logs {10741080 body.push_str(&format!("<h3>{}</h3>\n", esc(name)));1075- body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(content)));1081+ body.push_str(&format!(1082+ "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1083+ esc(content)1084+ ));10761085 }10771086 }10781087 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),
src/releases.rs+4 −4--- a/src/releases.rs+++ b/src/releases.rs@@ -350,12 +350,12 @@ fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {350350 body.push_str("</ul>\n");351351 let hashes = sha256_lines(&rel.body);352352 if !hashes.is_empty() {353- body.push_str("<h2>Checksums</h2>\n<pre class=\"log\">");353+ body.push_str("<h2>Checksums</h2>\n<div class=\"pre-scroll\"><pre class=\"log\">");354354 for h in &hashes {355355 body.push_str(&esc(h));356356 body.push('\n');357357 }358- body.push_str("</pre>\n");358+ body.push_str("</pre></div>\n");359359 }360360 }361361 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");@@ -380,7 +380,7 @@ pub fn render_loaded(380380 if releases.is_empty() {381381 body.push_str("<p class=\"muted\">No releases yet.</p>\n");382382 } else {383- body.push_str("<table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");383+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");384384 for rel in releases {385385 body.push_str(&format!(386386 "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",@@ -391,7 +391,7 @@ pub fn render_loaded(391391 n = rel.artifacts.len(),392392 ));393393 }394- body.push_str("</tbody>\n</table>\n");394+ body.push_str("</tbody>\n</table></div>\n");395395 }396396 let index = page(&format!("releases — {site_name}"), nav, &body);397397 let dest = out.join("releases").join("index.html");
static/style.css+43 −17--- a/static/style.css+++ b/static/style.css@@ -4,47 +4,73 @@44 yellow #e0af68, red #f7768e, border #292e42. */55 *{box-sizing:border-box;border-radius:0}66 :root{color-scheme:dark}7-html{background:#1a1b26}7+html{background:#1a1b26;-webkit-text-size-adjust:100%}88 body{margin:0;background:#1a1b26;color:#c0caf5;99 font-family:system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,Cantarell,sans-serif;10- line-height:1.55;font-size:16px}11-.wrap{max-width:72rem;margin:0 auto;padding:0 1rem}10+ line-height:1.55;font-size:16px;overflow-x:clip}11+img,video,svg{max-width:100%;height:auto}12+/* Fluid container: base = mobile single-column, side padding, never fixed px. */13+.wrap{max-width:100%;margin:0 auto;padding:0 1rem;min-width:0}1214 .site-header{background:#16161e;border-bottom:1px solid #292e42}13-.site-header .wrap{display:flex;align-items:center;gap:1rem;padding:.75rem 1rem}14-.brand{font-weight:700;color:#7aa2f7}15+.site-header .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;padding:.75rem 1rem}16+.brand{font-weight:700;color:#7aa2f7;overflow-wrap:anywhere}1517 .site-nav{background:#16161e;border-bottom:1px solid #292e42}1618 .site-nav .wrap{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 1rem}17-.site-nav a{color:#7dcfff;text-decoration:none;padding:.15rem 0}19+.site-nav a{display:inline-flex;align-items:center;min-height:40px;color:#7dcfff;text-decoration:none;padding:.35rem .1rem}1820 .site-nav a:hover,.site-nav a:focus{text-decoration:underline}1921 a{color:#7aa2f7}2022 a:visited{color:#9aa5ce}21-main{padding:1.25rem 0 3rem}22-h1,h2,h3{color:#c0caf5;line-height:1.25}23+main{padding:1.25rem 0 3rem;min-width:0}24+/* Repo header stacks on narrow; clone cmd + sha wrap safely. */25+.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}26+.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}27+.clone,.repo-meta{margin:.25rem 0;min-width:0}28+.clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}2329 code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;24- background:#16161e;color:#9ece6a;padding:.1em .3em}30+ background:#16161e;color:#9ece6a;padding:.1em .3em;overflow-wrap:anywhere}2531 pre{background:#16161e;color:#c0caf5;border:1px solid #292e42;26- padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5}27-pre code{background:none;padding:0;color:inherit}32+ padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5;max-width:100%}33+pre code{background:none;padding:0;color:inherit;overflow-wrap:normal;word-break:normal}34+/* Scroll containers: tables never break layout; pre scrolls inside too. */35+.table-scroll,.pre-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}36+.table-scroll{margin:1rem 0}37+.pre-scroll{margin:1rem 0}38+.table-scroll table{margin:0}39+.pre-scroll pre{margin:0}2840 table{border-collapse:collapse;width:100%;margin:1rem 0;background:#16161e}29-th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top}41+th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top;overflow-wrap:anywhere}3042 th{background:#1f2335;color:#7dcfff}3143 tr:nth-child(even) td{background:#1a1b26}32-form{max-width:44rem;margin:1rem 0}44+td code,th code{word-break:break-all}45+/* Single column on mobile; side-by-side only when it fits (>=768px). */46+.cols{display:grid;grid-template-columns:minmax(0,1fr);gap:1.5rem;min-width:0}47+.cols>section,.cols>div{min-width:0}48+/* Forms: full-width on mobile, >=40px touch targets. */49+form{max-width:44rem;margin:1rem 0;min-width:0}3350 label{display:block;margin:.75rem 0 .25rem;font-weight:600}34-input[type=text],textarea{display:block;width:100%;background:#16161e;35- color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit}51+input[type=text],textarea{display:block;width:100%;max-width:100%;background:#16161e;52+ color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit;min-height:40px}3653 input[type=text]:focus,textarea:focus{outline:2px solid #7aa2f7;outline-offset:0}3754 textarea{min-height:12rem}38-button,input[type=submit]{background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7;55+button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;56+ min-height:40px;min-width:40px;background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7;3957 padding:.5rem 1rem;font:inherit;font-weight:700;cursor:pointer}4058 button:hover,input[type=submit]:hover{background:#7dcfff}4159 .muted{color:#565f89}4260 .badge{display:inline-block;border:1px solid #292e42;padding:.1em .5em;font-size:.85em}4361 .badge.pass{color:#9ece6a}.badge.fail{color:#f7768e}4462 .badge.running{color:#e0af68}.badge.cancelled{color:#565f89}45-.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0}63+.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0;overflow-wrap:anywhere}4664 footer.site-footer{border-top:1px solid #292e42;color:#565f89}4765 footer.site-footer .wrap{padding:1rem}66+@media (min-width:768px){67+ .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}68+ .site-nav .wrap{gap:.25rem 1.25rem}69+ main{padding:1.5rem 0 3rem}70+}71+@media (min-width:1100px){72+ .wrap{max-width:72rem;padding:0 1.5rem}73+}4874 @media (prefers-color-scheme:light){4975 html,body{background:#d5d6db;color:#343b58}5076 .site-header,.site-nav,code,kbd,samp,pre,table,input[type=text],textarea{background:#cbccd3}
tests/responsive.rs+287 −1--- /dev/null+++ b/tests/responsive.rs@@ -0,0 +1,287 @@1+//! Responsive pass (m6, f0rge issue #14):2+//! viewport meta, breakpoints, scroll wrappers, CSS budget, zero JS.3+//!4+//! Builds a small fixture repo with the real binary and string-asserts on5+//! the emitted HTML + `static/style.css` (no rendering engine needed).6+7+use std::path::{Path, PathBuf};8+use std::process::Command;9+10+fn bin() -> &'static str {11+ env!("CARGO_BIN_EXE_hu13")12+}13+14+fn manifest() -> PathBuf {15+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))16+}17+18+fn fresh(prefix: &str) -> PathBuf {19+ let dir = std::env::temp_dir().join(format!(20+ "hu13-responsive-{prefix}-{}-{}",21+ std::process::id(),22+ std::time::SystemTime::now()23+ .duration_since(std::time::UNIX_EPOCH)24+ .map(|d| d.as_nanos())25+ .unwrap_or(0)26+ ));27+ std::fs::create_dir_all(&dir).unwrap();28+ dir29+}30+31+fn git(repo: &Path, args: &[&str]) {32+ let st = Command::new("git")33+ .arg("-C")34+ .arg(repo)35+ .args(args)36+ .status()37+ .expect("spawn git");38+ assert!(st.success(), "git {args:?} failed");39+}40+41+/// Fixture: README + one long-line text file + 2 commits (list + detail).42+fn fixture_repo() -> PathBuf {43+ let dir = fresh("repo");44+ git(&dir, &["init", "-b", "main"]);45+ git(&dir, &["config", "user.name", "hu13-test"]);46+ git(&dir, &["config", "user.email", "[email protected]"]);47+ git(&dir, &["config", "commit.gpgsign", "false"]);48+ std::fs::write(dir.join("README.md"), "# responsive fixture\n\nhello\n").unwrap();49+ // Long unbroken line: would force page scroll without a scroll container.50+ std::fs::write(51+ dir.join("notes.txt"),52+ format!("{}\nsecond line\n", "x".repeat(500)),53+ )54+ .unwrap();55+ git(&dir, &["add", "-A"]);56+ git(&dir, &["commit", "-m", "first commit"]);57+ std::fs::write(58+ dir.join("notes.txt"),59+ format!("{}\nsecond line\nthird line\n", "y".repeat(500)),60+ )61+ .unwrap();62+ git(&dir, &["add", "-A"]);63+ git(&dir, &["commit", "-m", "second commit"]);64+ dir65+}66+67+fn build_fixture() -> PathBuf {68+ let repo = fixture_repo();69+ let out = fresh("out");70+ let st = Command::new(bin())71+ .arg("build")72+ .arg(&repo)73+ .arg("-o")74+ .arg(&out)75+ .status()76+ .expect("spawn hu13 build");77+ assert!(st.success(), "fixture build exits 0");78+ out79+}80+81+fn css() -> String {82+ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")83+}84+85+#[test]86+fn viewport_meta_on_all_pages() {87+ let out = build_fixture();88+ let commit_dir = out.join("commit");89+ let sha_page = std::fs::read_dir(&commit_dir)90+ .expect("commit dir")91+ .filter_map(|e| e.ok())92+ .map(|e| e.path())93+ .find(|p| p.extension().is_some_and(|x| x == "html"))94+ .expect("one commit page");95+ for page in [96+ out.join("index.html"),97+ out.join("files").join("notes.txt.html"),98+ out.join("commits.html"),99+ sha_page,100+ ] {101+ let html = std::fs::read_to_string(&page).expect("read page");102+ assert!(103+ html.contains(104+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"105+ ),106+ "{} carries the viewport meta",107+ page.display()108+ );109+ }110+}111+112+#[test]113+fn css_breakpoints_present() {114+ let css = css();115+ assert!(116+ css.contains("@media (min-width:768px)"),117+ "768px breakpoint present"118+ );119+ assert!(120+ css.contains("@media (min-width:1100px)"),121+ "1100px breakpoint present"122+ );123+ let queries = css.matches("@media").count();124+ assert!(queries >= 2, "at least 2 media queries, got {queries}");125+}126+127+#[test]128+fn scroll_wrappers_on_file_and_commit_pages() {129+ let out = build_fixture();130+ // File page: long-line <pre> sits inside a scroll container.131+ let file = std::fs::read_to_string(out.join("files").join("notes.txt.html")).expect("file");132+ assert!(133+ file.contains("pre-scroll"),134+ "file page has pre scroll wrapper"135+ );136+ assert!(file.contains("<pre"), "file page keeps <pre>");137+ // Commit list: table inside a horizontal-scroll container.138+ let list = std::fs::read_to_string(out.join("commits.html")).expect("list");139+ assert!(140+ list.contains("table-scroll"),141+ "commits.html wraps its table"142+ );143+ assert!(list.contains("<table"), "commits.html keeps <table>");144+ // Commit detail: message + diff pres wrapped.145+ let commit_dir = out.join("commit");146+ let sha_page = std::fs::read_dir(&commit_dir)147+ .expect("commit dir")148+ .filter_map(|e| e.ok())149+ .map(|e| e.path())150+ .find(|p| p.extension().is_some_and(|x| x == "html"))151+ .expect("one commit page");152+ let detail = std::fs::read_to_string(&sha_page).expect("detail");153+ assert!(154+ detail.contains("pre-scroll"),155+ "commit detail wraps its pres"156+ );157+ // CSS side: wrappers scroll, pre scrolls, table never forces the page.158+ let css = css();159+ for needle in ["table-scroll", "pre-scroll", "overflow-x:auto"] {160+ assert!(css.contains(needle), "CSS mentions {needle}");161+ }162+}163+164+#[test]165+fn css_budget_and_sharp_corners() {166+ let path = manifest().join("static").join("style.css");167+ let len = std::fs::metadata(&path).expect("style.css exists").len();168+ assert!(169+ len < 15360,170+ "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"171+ );172+ let css = css();173+ for line in css.lines() {174+ let mut rest = line;175+ while let Some(i) = rest.find("border-radius") {176+ let after = &rest[i + "border-radius".len()..];177+ let value: String = after178+ .chars()179+ .skip_while(|c| *c == ':' || c.is_whitespace())180+ .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t'))181+ .collect();182+ assert!(183+ value == "0",184+ "only allowed border-radius value is 0, got {value:?} in {line:?}"185+ );186+ rest = &rest[i + "border-radius".len()..];187+ }188+ }189+}190+191+#[test]192+fn no_fixed_pixel_widths_on_layout() {193+ let css = css();194+ for line in css.lines() {195+ let bytes = line.as_bytes();196+ let mut i = 0;197+ while let Some(pos) = line[i..].find("width:") {198+ let abs = i + pos;199+ // Skip `min-width:` / `max-width:` (responsive + touch targets).200+ let is_prefixed =201+ abs > 0 && bytes[abs - 1] == b'-' && line[..abs].ends_with(['-', 'n', 'x']);202+ let prefixed = line[..abs].ends_with("min-") || line[..abs].ends_with("max-");203+ if !is_prefixed && !prefixed {204+ let after = line[abs + "width:".len()..].trim_start();205+ let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();206+ let unit: String = after[num.len()..].chars().take(2).collect();207+ assert!(208+ !(!num.is_empty() && unit == "px"),209+ "no fixed `width: <N>px` on layout containers, got: {line:?}"210+ );211+ }212+ i = abs + "width:".len();213+ }214+ }215+ // Fluid container: max-width + side padding, never a fixed width.216+ assert!(css.contains(".wrap"), ".wrap container styled");217+ assert!(css.contains("max-width"), ".wrap uses max-width");218+}219+220+#[test]221+fn touch_targets_and_stacking_header() {222+ let css = css();223+ assert!(224+ css.contains("min-height:40px"),225+ ">=40px touch targets for nav/form/buttons"226+ );227+ assert!(228+ css.contains("flex-wrap"),229+ "nav/header wrap on narrow screens"230+ );231+ for needle in [".repo-head", ".clone", ".cols"] {232+ assert!(css.contains(needle), "CSS styles {needle}");233+ }234+ let out = build_fixture();235+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");236+ for needle in ["repo-head", "cols", "repo-meta"] {237+ assert!(index.contains(needle), "index.html has {needle}");238+ }239+}240+241+#[test]242+fn zero_js_and_reader_friendly() {243+ let out = build_fixture();244+ let mut pages = vec![245+ out.join("index.html"),246+ out.join("commits.html"),247+ out.join("files").join("notes.txt.html"),248+ ];249+ if let Ok(rd) = std::fs::read_dir(out.join("commit")) {250+ for e in rd.filter_map(|e| e.ok()) {251+ if e.path().extension().is_some_and(|x| x == "html") {252+ pages.push(e.path());253+ break;254+ }255+ }256+ }257+ for page in &pages {258+ let html = std::fs::read_to_string(page).expect("read page");259+ assert!(260+ !html.contains("<script"),261+ "{} has no <script>",262+ page.display()263+ );264+ assert!(265+ !html.contains("onclick="),266+ "{} has no handlers",267+ page.display()268+ );269+ }270+ // No JS files emitted anywhere under dist/.271+ let mut stack = vec![out.clone()];272+ while let Some(d) = stack.pop() {273+ for e in std::fs::read_dir(&d).expect("read_dir") {274+ let e = e.expect("entry");275+ let p = e.path();276+ if p.is_dir() {277+ stack.push(p);278+ } else {279+ assert!(280+ p.extension().is_none_or(|x| x != "js"),281+ "no .js emitted: {}",282+ p.display()283+ );284+ }285+ }286+ }287+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.