#13 m6/responsive

mergedpassopened
details
branches
master ← m6/responsive
updated
id
350cc5b1-3b47-43be-9f70-7af6fa4ea365

Patch

src/build.rs+14 −11
diff --git a/src/build.rs b/src/build.rsindex f6bd309..09c8f51 100644--- 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
diff --git a/src/commits.rs b/src/commits.rsindex 593c791..a98c017 100644--- 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
diff --git a/src/forge.rs b/src/forge.rsindex 9e4e9c5..868449e 100644--- 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
diff --git a/src/releases.rs b/src/releases.rsindex 0abcb92..e05fe4c 100644--- 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
diff --git a/static/style.css b/static/style.cssindex 52f0864..2bac0f6 100644--- 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
diff --git a/tests/responsive.rs b/tests/responsive.rsnew file mode 100644index 0000000..1b21cf9--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls