.forge/prs/350cc5b1-3b47-43be-9f70-7af6fa4ea365/patch.diff 27.1 KiB 645 lines Raw
1 From 0fcdc135540c2aa61215bdfc45b7bf997567ff94 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:23:44 +01004 Subject: [PATCH] m6/responsive: fluid layout, wrapping nav, scroll containers,5  touch targets (113 tests)6 7 ---8  src/build.rs        |  25 ++--9  src/commits.rs      |   6 +-10  src/forge.rs        |  27 +++--11  src/releases.rs     |   8 +-12  static/style.css    |  60 ++++++---13  tests/responsive.rs | 287 ++++++++++++++++++++++++++++++++++++++++++++14  6 files changed, 369 insertions(+), 44 deletions(-)15  create mode 100644 tests/responsive.rs16 17 diff --git a/src/build.rs b/src/build.rs18 index f6bd309..09c8f51 10064419 --- a/src/build.rs20 +++ b/src/build.rs21 @@ -135,9 +135,10 @@ fn href_esc(path: &str) -> String {22      esc(path)23  }24  25 -/// Render one text file body: escaped `<pre>` with line numbers.26 +/// Render one text file body: escaped `<pre>` with line numbers, wrapped in27 +/// a horizontal-scroll container so long lines never break the 360px layout.28  fn render_text_pre(display: &str) -> String {29 -    let mut out = String::from("<pre class=\"code\">\n");30 +    let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");31      if display.is_empty() {32          out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");33      } else {34 @@ -149,7 +150,7 @@ fn render_text_pre(display: &str) -> String {35              out.push_str("</span>\n");36          }37      }38 -    out.push_str("</pre>\n");39 +    out.push_str("</pre></div>\n");40      out41  }42  43 @@ -381,20 +382,21 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu44          .map(|m| m.len())45          .unwrap_or(0);46  47 -    // index.html header.48 -    let mut body = format!("<h1>{}</h1>\n", esc(&name));49 +    // index.html header: stacked repo-head (name + clone + meta) so narrow50 +    // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.51 +    let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));52      match &description {53          Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),54          None => body.push_str("<p class=\"muted\">No description.</p>\n"),55      }56      if let Some(url) = &clone_url {57          body.push_str(&format!(58 -            "<p>Clone: <code>git clone {}</code></p>\n",59 +            "<p class=\"clone\">Clone: <code>git clone {}</code></p>\n",60              esc(url)61          ));62      }63      body.push_str(&format!(64 -        "<p>Branch <code>{}</code> @ <code>{}</code></p>\n",65 +        "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",66          esc(&branch),67          esc(&short_sha)68      ));69 @@ -417,7 +419,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu70           <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"71      ));72  73 -    body.push_str("<h2>Files</h2>\n<ul class=\"tree\">\n");74 +    body.push_str("</div>\n");75 +    // Single column on mobile; tree+readme sit side by side only at >=768px.76 +    body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n<ul class=\"tree\">\n");77      for e in dir_entries.get("").cloned().unwrap_or_default() {78          if e.is_dir {79              body.push_str(&format!(80 @@ -434,9 +438,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu81              ));82          }83      }84 -    body.push_str("</ul>\n");85 -86 -    body.push_str("<h2>README</h2>\n");87 +    body.push_str("</ul>\n</section>\n<section><h2>README</h2>\n");88      match &readme {89          Some((path, blob)) => {90              body.push_str(&format!(91 @@ -451,6 +453,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu92          }93          None => body.push_str("<p class=\"muted\">No README found.</p>\n"),94      }95 +    body.push_str("</section></div>\n");96  97      let index = crate::layout::page(&name, &nav, &body);98      std::fs::write(out.join("index.html"), index)99 diff --git a/src/commits.rs b/src/commits.rs100 index 593c791..a98c017 100644101 --- a/src/commits.rs102 +++ b/src/commits.rs103 @@ -35,7 +35,7 @@ pub fn render_loaded(104      if log.is_empty() {105          body.push_str("<p class=\"muted\">No commits yet.</p>\n");106      } else {107 -        body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");108 +        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");109          for c in log {110              body.push_str(&format!(111                  "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",112 @@ -46,7 +46,7 @@ pub fn render_loaded(113                  esc(&c.date),114              ));115          }116 -        body.push_str("</tbody>\n</table>\n");117 +        body.push_str("</tbody>\n</table></div>\n");118      }119      let html = page(&format!("commits — {site_name}"), nav, &body);120      std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;121 @@ -59,7 +59,7 @@ pub fn render_loaded(122          let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;123          let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;124          let detail = format!(125 -            "<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",126 +            "<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",127              esc(short_sha(&c.sha)),128              esc(&c.sha),129              esc(&c.author),130 diff --git a/src/forge.rs b/src/forge.rs131 index 9e4e9c5..868449e 100644132 --- a/src/forge.rs133 +++ b/src/forge.rs134 @@ -889,7 +889,10 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {135      body.push_str("<h2>Patch</h2>\n");136      match &pr.patch {137          Some(patch) if !patch.is_empty() => {138 -            body.push_str(&format!("<pre class=\"diff\">{}</pre>\n", esc(patch)));139 +            body.push_str(&format!(140 +                "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",141 +                esc(patch)142 +            ));143          }144          _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),145      }146 @@ -934,7 +937,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S147      if data.issues.is_empty() {148          body.push_str("<p class=\"muted\">No issues yet.</p>\n");149      } else {150 -        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");151 +        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");152          for issue in &data.issues {153              body.push_str(&format!(154                  "<tr><td>{n}</td><td><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",155 @@ -946,7 +949,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S156                  created = esc(&issue.created),157              ));158          }159 -        body.push_str("</tbody>\n</table>\n");160 +        body.push_str("</tbody>\n</table></div>\n");161      }162      if !data.labels.is_empty() {163          body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");164 @@ -974,7 +977,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {165      if prs.is_empty() {166          body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");167      } else {168 -        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");169 +        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");170          for pr in prs {171              let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));172              let draft = if pr.draft { " draft" } else { "" };173 @@ -987,7 +990,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {174                  created = esc(&pr.created),175              ));176          }177 -        body.push_str("</tbody>\n</table>\n");178 +        body.push_str("</tbody>\n</table></div>\n");179      }180      page(&format!("pulls — {site_name}"), nav, &body)181  }182 @@ -1053,7 +1056,7 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {183              if run.steps.is_empty() {184                  body.push_str("<p class=\"muted\">No step results recorded.</p>\n");185              } else {186 -                body.push_str("<table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");187 +                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");188                  for s in &run.steps {189                      body.push_str(&format!(190                          "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",191 @@ -1062,17 +1065,23 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {192                          s.duration_ms,193                      ));194                  }195 -                body.push_str("</tbody>\n</table>\n");196 +                body.push_str("</tbody>\n</table></div>\n");197              }198              body.push_str("<h3>Log</h3>\n");199              if run.body.trim().is_empty() {200                  body.push_str("<p class=\"muted\">Empty log.</p>\n");201              } else {202 -                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(&run.body)));203 +                body.push_str(&format!(204 +                    "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",205 +                    esc(&run.body)206 +                ));207              }208              for (name, content) in &run.logs {209                  body.push_str(&format!("<h3>{}</h3>\n", esc(name)));210 -                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(content)));211 +                body.push_str(&format!(212 +                    "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",213 +                    esc(content)214 +                ));215              }216          }217          None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),218 diff --git a/src/releases.rs b/src/releases.rs219 index 0abcb92..e05fe4c 100644220 --- a/src/releases.rs221 +++ b/src/releases.rs222 @@ -350,12 +350,12 @@ fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {223          body.push_str("</ul>\n");224          let hashes = sha256_lines(&rel.body);225          if !hashes.is_empty() {226 -            body.push_str("<h2>Checksums</h2>\n<pre class=\"log\">");227 +            body.push_str("<h2>Checksums</h2>\n<div class=\"pre-scroll\"><pre class=\"log\">");228              for h in &hashes {229                  body.push_str(&esc(h));230                  body.push('\n');231              }232 -            body.push_str("</pre>\n");233 +            body.push_str("</pre></div>\n");234          }235      }236      body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");237 @@ -380,7 +380,7 @@ pub fn render_loaded(238      if releases.is_empty() {239          body.push_str("<p class=\"muted\">No releases yet.</p>\n");240      } else {241 -        body.push_str("<table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");242 +        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");243          for rel in releases {244              body.push_str(&format!(245                  "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",246 @@ -391,7 +391,7 @@ pub fn render_loaded(247                  n = rel.artifacts.len(),248              ));249          }250 -        body.push_str("</tbody>\n</table>\n");251 +        body.push_str("</tbody>\n</table></div>\n");252      }253      let index = page(&format!("releases — {site_name}"), nav, &body);254      let dest = out.join("releases").join("index.html");255 diff --git a/static/style.css b/static/style.css256 index 52f0864..2bac0f6 100644257 --- a/static/style.css258 +++ b/static/style.css259 @@ -4,47 +4,73 @@260     yellow #e0af68, red #f7768e, border #292e42. */261  *{box-sizing:border-box;border-radius:0}262  :root{color-scheme:dark}263 -html{background:#1a1b26}264 +html{background:#1a1b26;-webkit-text-size-adjust:100%}265  body{margin:0;background:#1a1b26;color:#c0caf5;266    font-family:system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,Cantarell,sans-serif;267 -  line-height:1.55;font-size:16px}268 -.wrap{max-width:72rem;margin:0 auto;padding:0 1rem}269 +  line-height:1.55;font-size:16px;overflow-x:clip}270 +img,video,svg{max-width:100%;height:auto}271 +/* Fluid container: base = mobile single-column, side padding, never fixed px. */272 +.wrap{max-width:100%;margin:0 auto;padding:0 1rem;min-width:0}273  .site-header{background:#16161e;border-bottom:1px solid #292e42}274 -.site-header .wrap{display:flex;align-items:center;gap:1rem;padding:.75rem 1rem}275 -.brand{font-weight:700;color:#7aa2f7}276 +.site-header .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;padding:.75rem 1rem}277 +.brand{font-weight:700;color:#7aa2f7;overflow-wrap:anywhere}278  .site-nav{background:#16161e;border-bottom:1px solid #292e42}279  .site-nav .wrap{display:flex;flex-wrap:wrap;gap:.25rem 1rem;padding:.5rem 1rem}280 -.site-nav a{color:#7dcfff;text-decoration:none;padding:.15rem 0}281 +.site-nav a{display:inline-flex;align-items:center;min-height:40px;color:#7dcfff;text-decoration:none;padding:.35rem .1rem}282  .site-nav a:hover,.site-nav a:focus{text-decoration:underline}283  a{color:#7aa2f7}284  a:visited{color:#9aa5ce}285 -main{padding:1.25rem 0 3rem}286 -h1,h2,h3{color:#c0caf5;line-height:1.25}287 +main{padding:1.25rem 0 3rem;min-width:0}288 +/* Repo header stacks on narrow; clone cmd + sha wrap safely. */289 +.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}290 +.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}291 +.clone,.repo-meta{margin:.25rem 0;min-width:0}292 +.clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}293  code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;294 -  background:#16161e;color:#9ece6a;padding:.1em .3em}295 +  background:#16161e;color:#9ece6a;padding:.1em .3em;overflow-wrap:anywhere}296  pre{background:#16161e;color:#c0caf5;border:1px solid #292e42;297 -  padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5}298 -pre code{background:none;padding:0;color:inherit}299 +  padding:1rem;overflow-x:auto;white-space:pre;line-height:1.5;max-width:100%}300 +pre code{background:none;padding:0;color:inherit;overflow-wrap:normal;word-break:normal}301 +/* Scroll containers: tables never break layout; pre scrolls inside too. */302 +.table-scroll,.pre-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}303 +.table-scroll{margin:1rem 0}304 +.pre-scroll{margin:1rem 0}305 +.table-scroll table{margin:0}306 +.pre-scroll pre{margin:0}307  table{border-collapse:collapse;width:100%;margin:1rem 0;background:#16161e}308 -th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top}309 +th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-align:top;overflow-wrap:anywhere}310  th{background:#1f2335;color:#7dcfff}311  tr:nth-child(even) td{background:#1a1b26}312 -form{max-width:44rem;margin:1rem 0}313 +td code,th code{word-break:break-all}314 +/* Single column on mobile; side-by-side only when it fits (>=768px). */315 +.cols{display:grid;grid-template-columns:minmax(0,1fr);gap:1.5rem;min-width:0}316 +.cols>section,.cols>div{min-width:0}317 +/* Forms: full-width on mobile, >=40px touch targets. */318 +form{max-width:44rem;margin:1rem 0;min-width:0}319  label{display:block;margin:.75rem 0 .25rem;font-weight:600}320 -input[type=text],textarea{display:block;width:100%;background:#16161e;321 -  color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit}322 +input[type=text],textarea{display:block;width:100%;max-width:100%;background:#16161e;323 +  color:#c0caf5;border:1px solid #292e42;padding:.5rem .6rem;font:inherit;min-height:40px}324  input[type=text]:focus,textarea:focus{outline:2px solid #7aa2f7;outline-offset:0}325  textarea{min-height:12rem}326 -button,input[type=submit]{background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7;327 +button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;328 +  min-height:40px;min-width:40px;background:#7aa2f7;color:#16161e;border:1px solid #7aa2f7;329    padding:.5rem 1rem;font:inherit;font-weight:700;cursor:pointer}330  button:hover,input[type=submit]:hover{background:#7dcfff}331  .muted{color:#565f89}332  .badge{display:inline-block;border:1px solid #292e42;padding:.1em .5em;font-size:.85em}333  .badge.pass{color:#9ece6a}.badge.fail{color:#f7768e}334  .badge.running{color:#e0af68}.badge.cancelled{color:#565f89}335 -.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0}336 +.notice{border:1px solid #e0af68;padding:.75rem 1rem;margin:1rem 0;overflow-wrap:anywhere}337  footer.site-footer{border-top:1px solid #292e42;color:#565f89}338  footer.site-footer .wrap{padding:1rem}339 +@media (min-width:768px){340 +  .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}341 +  .site-nav .wrap{gap:.25rem 1.25rem}342 +  main{padding:1.5rem 0 3rem}343 +}344 +@media (min-width:1100px){345 +  .wrap{max-width:72rem;padding:0 1.5rem}346 +}347  @media (prefers-color-scheme:light){348    html,body{background:#d5d6db;color:#343b58}349    .site-header,.site-nav,code,kbd,samp,pre,table,input[type=text],textarea{background:#cbccd3}350 diff --git a/tests/responsive.rs b/tests/responsive.rs351 new file mode 100644352 index 0000000..1b21cf9353 --- /dev/null354 +++ b/tests/responsive.rs355 @@ -0,0 +1,287 @@356 +//! Responsive pass (m6, f0rge issue #14):357 +//! viewport meta, breakpoints, scroll wrappers, CSS budget, zero JS.358 +//!359 +//! Builds a small fixture repo with the real binary and string-asserts on360 +//! the emitted HTML + `static/style.css` (no rendering engine needed).361 +362 +use std::path::{Path, PathBuf};363 +use std::process::Command;364 +365 +fn bin() -> &'static str {366 +    env!("CARGO_BIN_EXE_hu13")367 +}368 +369 +fn manifest() -> PathBuf {370 +    PathBuf::from(env!("CARGO_MANIFEST_DIR"))371 +}372 +373 +fn fresh(prefix: &str) -> PathBuf {374 +    let dir = std::env::temp_dir().join(format!(375 +        "hu13-responsive-{prefix}-{}-{}",376 +        std::process::id(),377 +        std::time::SystemTime::now()378 +            .duration_since(std::time::UNIX_EPOCH)379 +            .map(|d| d.as_nanos())380 +            .unwrap_or(0)381 +    ));382 +    std::fs::create_dir_all(&dir).unwrap();383 +    dir384 +}385 +386 +fn git(repo: &Path, args: &[&str]) {387 +    let st = Command::new("git")388 +        .arg("-C")389 +        .arg(repo)390 +        .args(args)391 +        .status()392 +        .expect("spawn git");393 +    assert!(st.success(), "git {args:?} failed");394 +}395 +396 +/// Fixture: README + one long-line text file + 2 commits (list + detail).397 +fn fixture_repo() -> PathBuf {398 +    let dir = fresh("repo");399 +    git(&dir, &["init", "-b", "main"]);400 +    git(&dir, &["config", "user.name", "hu13-test"]);401 +    git(&dir, &["config", "user.email", "[email protected]"]);402 +    git(&dir, &["config", "commit.gpgsign", "false"]);403 +    std::fs::write(dir.join("README.md"), "# responsive fixture\n\nhello\n").unwrap();404 +    // Long unbroken line: would force page scroll without a scroll container.405 +    std::fs::write(406 +        dir.join("notes.txt"),407 +        format!("{}\nsecond line\n", "x".repeat(500)),408 +    )409 +    .unwrap();410 +    git(&dir, &["add", "-A"]);411 +    git(&dir, &["commit", "-m", "first commit"]);412 +    std::fs::write(413 +        dir.join("notes.txt"),414 +        format!("{}\nsecond line\nthird line\n", "y".repeat(500)),415 +    )416 +    .unwrap();417 +    git(&dir, &["add", "-A"]);418 +    git(&dir, &["commit", "-m", "second commit"]);419 +    dir420 +}421 +422 +fn build_fixture() -> PathBuf {423 +    let repo = fixture_repo();424 +    let out = fresh("out");425 +    let st = Command::new(bin())426 +        .arg("build")427 +        .arg(&repo)428 +        .arg("-o")429 +        .arg(&out)430 +        .status()431 +        .expect("spawn hu13 build");432 +    assert!(st.success(), "fixture build exits 0");433 +    out434 +}435 +436 +fn css() -> String {437 +    std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")438 +}439 +440 +#[test]441 +fn viewport_meta_on_all_pages() {442 +    let out = build_fixture();443 +    let commit_dir = out.join("commit");444 +    let sha_page = std::fs::read_dir(&commit_dir)445 +        .expect("commit dir")446 +        .filter_map(|e| e.ok())447 +        .map(|e| e.path())448 +        .find(|p| p.extension().is_some_and(|x| x == "html"))449 +        .expect("one commit page");450 +    for page in [451 +        out.join("index.html"),452 +        out.join("files").join("notes.txt.html"),453 +        out.join("commits.html"),454 +        sha_page,455 +    ] {456 +        let html = std::fs::read_to_string(&page).expect("read page");457 +        assert!(458 +            html.contains(459 +                "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"460 +            ),461 +            "{} carries the viewport meta",462 +            page.display()463 +        );464 +    }465 +}466 +467 +#[test]468 +fn css_breakpoints_present() {469 +    let css = css();470 +    assert!(471 +        css.contains("@media (min-width:768px)"),472 +        "768px breakpoint present"473 +    );474 +    assert!(475 +        css.contains("@media (min-width:1100px)"),476 +        "1100px breakpoint present"477 +    );478 +    let queries = css.matches("@media").count();479 +    assert!(queries >= 2, "at least 2 media queries, got {queries}");480 +}481 +482 +#[test]483 +fn scroll_wrappers_on_file_and_commit_pages() {484 +    let out = build_fixture();485 +    // File page: long-line <pre> sits inside a scroll container.486 +    let file = std::fs::read_to_string(out.join("files").join("notes.txt.html")).expect("file");487 +    assert!(488 +        file.contains("pre-scroll"),489 +        "file page has pre scroll wrapper"490 +    );491 +    assert!(file.contains("<pre"), "file page keeps <pre>");492 +    // Commit list: table inside a horizontal-scroll container.493 +    let list = std::fs::read_to_string(out.join("commits.html")).expect("list");494 +    assert!(495 +        list.contains("table-scroll"),496 +        "commits.html wraps its table"497 +    );498 +    assert!(list.contains("<table"), "commits.html keeps <table>");499 +    // Commit detail: message + diff pres wrapped.500 +    let commit_dir = out.join("commit");501 +    let sha_page = std::fs::read_dir(&commit_dir)502 +        .expect("commit dir")503 +        .filter_map(|e| e.ok())504 +        .map(|e| e.path())505 +        .find(|p| p.extension().is_some_and(|x| x == "html"))506 +        .expect("one commit page");507 +    let detail = std::fs::read_to_string(&sha_page).expect("detail");508 +    assert!(509 +        detail.contains("pre-scroll"),510 +        "commit detail wraps its pres"511 +    );512 +    // CSS side: wrappers scroll, pre scrolls, table never forces the page.513 +    let css = css();514 +    for needle in ["table-scroll", "pre-scroll", "overflow-x:auto"] {515 +        assert!(css.contains(needle), "CSS mentions {needle}");516 +    }517 +}518 +519 +#[test]520 +fn css_budget_and_sharp_corners() {521 +    let path = manifest().join("static").join("style.css");522 +    let len = std::fs::metadata(&path).expect("style.css exists").len();523 +    assert!(524 +        len < 15360,525 +        "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"526 +    );527 +    let css = css();528 +    for line in css.lines() {529 +        let mut rest = line;530 +        while let Some(i) = rest.find("border-radius") {531 +            let after = &rest[i + "border-radius".len()..];532 +            let value: String = after533 +                .chars()534 +                .skip_while(|c| *c == ':' || c.is_whitespace())535 +                .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t'))536 +                .collect();537 +            assert!(538 +                value == "0",539 +                "only allowed border-radius value is 0, got {value:?} in {line:?}"540 +            );541 +            rest = &rest[i + "border-radius".len()..];542 +        }543 +    }544 +}545 +546 +#[test]547 +fn no_fixed_pixel_widths_on_layout() {548 +    let css = css();549 +    for line in css.lines() {550 +        let bytes = line.as_bytes();551 +        let mut i = 0;552 +        while let Some(pos) = line[i..].find("width:") {553 +            let abs = i + pos;554 +            // Skip `min-width:` / `max-width:` (responsive + touch targets).555 +            let is_prefixed =556 +                abs > 0 && bytes[abs - 1] == b'-' && line[..abs].ends_with(['-', 'n', 'x']);557 +            let prefixed = line[..abs].ends_with("min-") || line[..abs].ends_with("max-");558 +            if !is_prefixed && !prefixed {559 +                let after = line[abs + "width:".len()..].trim_start();560 +                let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();561 +                let unit: String = after[num.len()..].chars().take(2).collect();562 +                assert!(563 +                    !(!num.is_empty() && unit == "px"),564 +                    "no fixed `width: <N>px` on layout containers, got: {line:?}"565 +                );566 +            }567 +            i = abs + "width:".len();568 +        }569 +    }570 +    // Fluid container: max-width + side padding, never a fixed width.571 +    assert!(css.contains(".wrap"), ".wrap container styled");572 +    assert!(css.contains("max-width"), ".wrap uses max-width");573 +}574 +575 +#[test]576 +fn touch_targets_and_stacking_header() {577 +    let css = css();578 +    assert!(579 +        css.contains("min-height:40px"),580 +        ">=40px touch targets for nav/form/buttons"581 +    );582 +    assert!(583 +        css.contains("flex-wrap"),584 +        "nav/header wrap on narrow screens"585 +    );586 +    for needle in [".repo-head", ".clone", ".cols"] {587 +        assert!(css.contains(needle), "CSS styles {needle}");588 +    }589 +    let out = build_fixture();590 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");591 +    for needle in ["repo-head", "cols", "repo-meta"] {592 +        assert!(index.contains(needle), "index.html has {needle}");593 +    }594 +}595 +596 +#[test]597 +fn zero_js_and_reader_friendly() {598 +    let out = build_fixture();599 +    let mut pages = vec![600 +        out.join("index.html"),601 +        out.join("commits.html"),602 +        out.join("files").join("notes.txt.html"),603 +    ];604 +    if let Ok(rd) = std::fs::read_dir(out.join("commit")) {605 +        for e in rd.filter_map(|e| e.ok()) {606 +            if e.path().extension().is_some_and(|x| x == "html") {607 +                pages.push(e.path());608 +                break;609 +            }610 +        }611 +    }612 +    for page in &pages {613 +        let html = std::fs::read_to_string(page).expect("read page");614 +        assert!(615 +            !html.contains("<script"),616 +            "{} has no <script>",617 +            page.display()618 +        );619 +        assert!(620 +            !html.contains("onclick="),621 +            "{} has no handlers",622 +            page.display()623 +        );624 +    }625 +    // No JS files emitted anywhere under dist/.626 +    let mut stack = vec![out.clone()];627 +    while let Some(d) = stack.pop() {628 +        for e in std::fs::read_dir(&d).expect("read_dir") {629 +            let e = e.expect("entry");630 +            let p = e.path();631 +            if p.is_dir() {632 +                stack.push(p);633 +            } else {634 +                assert!(635 +                    p.extension().is_none_or(|x| x != "js"),636 +                    "no .js emitted: {}",637 +                    p.display()638 +                );639 +            }640 +        }641 +    }642 +}643 -- 644 2.55.0645