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