1 From 92b745308da96c1b0bde1608af185550dfe3d023 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:39:28 +01004 Subject: [PATCH] m6/tables: scroll containers on all list tables (115 tests)5 6 ---7 src/commits.rs | 2 +-8 src/forge.rs | 4 +-9 src/releases.rs | 2 +-10 static/style.css | 3 +11 tests/forge_readers.rs | 16 ++--12 tests/releases_api.rs | 12 ++-13 tests/responsive.rs | 164 +++++++++++++++++++++++++++++++++++++++++14 7 files changed, 191 insertions(+), 12 deletions(-)15 16 diff --git a/src/commits.rs b/src/commits.rs17 index a98c017..d258631 10064418 --- a/src/commits.rs19 +++ b/src/commits.rs20 @@ -38,7 +38,7 @@ pub fn render_loaded(21 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");22 for c in log {23 body.push_str(&format!(24 - "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",25 + "<tr><td class=\"sha\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title\">{}</td><td>{}</td><td>{}</td></tr>\n",26 esc(&c.sha),27 esc(short_sha(&c.sha)),28 esc(&c.subject),29 diff --git a/src/forge.rs b/src/forge.rs30 index 868449e..de99cc1 10064431 --- a/src/forge.rs32 +++ b/src/forge.rs33 @@ -940,7 +940,7 @@ fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<S34 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");35 for issue in &data.issues {36 body.push_str(&format!(37 - "<tr><td>{n}</td><td><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",38 + "<tr><td class=\"num\">{n}</td><td class=\"title\"><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",39 n = issue.number,40 id = esc(&issue.id),41 title = esc(&issue.title),42 @@ -982,7 +982,7 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {43 let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));44 let draft = if pr.draft { " draft" } else { "" };45 body.push_str(&format!(46 - "<tr><td>{n}</td><td><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td>{state}</td><td><code>{lanes}</code></td><td>{created}</td></tr>\n",47 + "<tr><td class=\"num\">{n}</td><td class=\"title\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td>{state}</td><td><code>{lanes}</code></td><td>{created}</td></tr>\n",48 n = pr.number,49 id = esc(&pr.id),50 title = esc(&pr.title),51 diff --git a/src/releases.rs b/src/releases.rs52 index e05fe4c..d0de7fd 10064453 --- a/src/releases.rs54 +++ b/src/releases.rs55 @@ -383,7 +383,7 @@ pub fn render_loaded(56 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");57 for rel in releases {58 body.push_str(&format!(59 - "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",60 + "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title\">{t}</td><td>{c}</td><td>{n}</td></tr>\n",61 v = esc(&rel.version),62 ve = esc(&rel.version),63 t = esc(rel.display_title()),64 diff --git a/static/style.css b/static/style.css65 index 2bac0f6..32f05a8 10064466 --- a/static/style.css67 +++ b/static/style.css68 @@ -42,6 +42,9 @@ th,td{text-align:left;padding:.5rem .75rem;border:1px solid #292e42;vertical-ali69 th{background:#1f2335;color:#7dcfff}70 tr:nth-child(even) td{background:#1a1b26}71 td code,th code{word-break:break-all}72 +/* List tables (m6/tables): long titles/shas wrap inside the scroll box; the # column stays narrow. */73 +td.title,td.sha{overflow-wrap:anywhere}74 +td.num{width:1%;white-space:nowrap}75 /* Single column on mobile; side-by-side only when it fits (>=768px). */76 .cols{display:grid;grid-template-columns:minmax(0,1fr);gap:1.5rem;min-width:0}77 .cols>section,.cols>div{min-width:0}78 diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs79 index f512a39..fda2b8b 10064480 --- a/tests/forge_readers.rs81 +++ b/tests/forge_readers.rs82 @@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {83 84 // --- issues index: 2 rendered rows (broken one skipped) ---85 let issues = read("issues/index.html");86 - let rows = issues.matches("<tr><td>").count();87 + let rows = issues.matches("<tr><td class=\"num\">").count();88 assert_eq!(rows, 2, "rendered issues match file-count minus broken");89 assert!(!issues.contains("No title here"), "broken issue skipped");90 // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.91 assert!(92 issues.contains(&format!(93 - "<td>1</td><td><a href=\"/issues/{OLDER_UUID}.html\">"94 + "<td class=\"num\">1</td><td class=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"95 )),96 "issue #1 -> older uuid"97 );98 assert!(99 issues.contains(&format!(100 - "<td>2</td><td><a href=\"/issues/{NEWER_UUID}.html\">"101 + "<td class=\"num\">2</td><td class=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"102 )),103 "issue #2 -> newer uuid"104 );105 @@ -262,9 +262,15 @@ fn forge_fixture_renders_counts_numbers_and_ci() {106 107 // (4): pulls — same shape + head/base/draft/ci + patch <pre>.108 let pulls = read("pulls/index.html");109 - assert_eq!(pulls.matches("<tr><td>").count(), 1, "prs match file-count");110 + assert_eq!(111 + pulls.matches("<tr><td class=\"num\">").count(),112 + 1,113 + "prs match file-count"114 + );115 assert!(116 - pulls.contains(&format!("<td>1</td><td><a href=\"/pulls/{PR_UUID}.html\">")),117 + pulls.contains(&format!(118 + "<td class=\"num\">1</td><td class=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"119 + )),120 "pr #1 -> uuid"121 );122 let pr = read(&format!("pulls/{PR_UUID}.html"));123 diff --git a/tests/releases_api.rs b/tests/releases_api.rs124 index 57345e3..9ea9a71 100644125 --- a/tests/releases_api.rs126 +++ b/tests/releases_api.rs127 @@ -153,9 +153,15 @@ fn releases_artifacts_api_agents_feed() {128 );129 130 // --- api counts == html counts (same struct pass) ---131 - let issues_html = read("issues/index.html").matches("<tr><td>").count();132 - let pulls_html = read("pulls/index.html").matches("<tr><td>").count();133 - let commits_html = read("commits.html").matches("<tr><td>").count();134 + let issues_html = read("issues/index.html")135 + .matches("<tr><td class=\"num\">")136 + .count();137 + let pulls_html = read("pulls/index.html")138 + .matches("<tr><td class=\"num\">")139 + .count();140 + let commits_html = read("commits.html")141 + .matches("<tr><td class=\"sha\">")142 + .count();143 let releases_html = read("releases/index.html").matches("<tr><td>").count();144 let wiki_html = read("wiki/index.html")145 .matches("<li><a href=\"/wiki/")146 diff --git a/tests/responsive.rs b/tests/responsive.rs147 index 1b21cf9..37b536e 100644148 --- a/tests/responsive.rs149 +++ b/tests/responsive.rs150 @@ -285,3 +285,167 @@ fn zero_js_and_reader_friendly() {151 }152 }153 }154 +155 +fn forge_write(repo: &Path, rel: &str, content: &str) {156 + let dest = repo.join(".forge").join(rel);157 + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();158 + std::fs::write(&dest, content).unwrap();159 +}160 +161 +/// Long-title forge fixture (m6, f0rge issue #17): one 200-char unbroken162 +/// title on an issue (+ labels) and on a PR, plus one task, one CI run163 +/// with steps, and one release — so every list page renders non-empty.164 +/// Returns `(dist_dir, long_title)`.165 +fn build_long_title_fixture() -> (PathBuf, String) {166 + let repo = fixture_repo();167 + let long_title = "L".repeat(200);168 + forge_write(169 + &repo,170 + "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",171 + &format!(172 + "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {long_title}\nstate: open\nlabels:\n- kind:scroll-test\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nLong-title body.\n"173 + ),174 + );175 + forge_write(176 + &repo,177 + "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",178 + &format!(179 + "---\nid: bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb\ntitle: {long_title}\nstate: open\nbase: main\nhead: long-title-branch\ncreated: 2026-09-10T03:30:00Z\n---\nLong-title PR body.\n"180 + ),181 + );182 + forge_write(183 + &repo,184 + "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",185 + "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: Scroll task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",186 + );187 + forge_write(&repo, "labels.md", "- name: kind:scroll-test\n");188 + forge_write(&repo, "ci/ci.env", "runner: local\n");189 + forge_write(&repo, "ci/steps/00_wrap.sh", "#!/bin/sh\nexit 0\n");190 + forge_write(191 + &repo,192 + "ci/runs/2026-09-10T00-00-00Z/summary.md",193 + "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_wrap.sh\n exit: 0\n duration_ms: 3\n---\nwrap log\n",194 + );195 + forge_write(196 + &repo,197 + "releases/v9.9.md",198 + "---\nversion: v9.9\ntitle: Scroll release\ncreated: 2026-09-10T02:00:00Z\n---\nRelease notes.\n",199 + );200 + git(&repo, &["add", "-A"]);201 + git(&repo, &["commit", "-m", "long-title forge fixture"]);202 + let out = fresh("tables");203 + let st = Command::new(bin())204 + .arg("build")205 + .arg(&repo)206 + .arg("-o")207 + .arg(&out)208 + .status()209 + .expect("spawn hu13 build");210 + assert!(st.success(), "long-title fixture build exits 0");211 + (out, long_title)212 +}213 +214 +#[test]215 +fn forge_list_tables_scroll_and_wrap_long_titles() {216 + let (out, long_title) = build_long_title_fixture();217 + let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);218 + // Every table-bearing list page wraps its table in .table-scroll and219 + // leaves no bare <table> outside the scroll container. The wrapper is220 + // the 360px story: the table scrolls inside instead of forcing the page.221 + for page in [222 + "issues/index.html",223 + "pulls/index.html",224 + "commits.html",225 + "releases/index.html",226 + "ci/index.html",227 + ] {228 + let html = read(page);229 + assert!(html.contains("table-scroll"), "{page} wraps its table");230 + assert!(html.contains("<table"), "{page} keeps <table>");231 + let stripped = html.replace("<div class=\"table-scroll\"><table>", "");232 + assert!(233 + !stripped.contains("<table"),234 + "{page} has no bare <table> outside .table-scroll"235 + );236 + }237 + // Tasks list renders grouped <ul>s, never a table.238 + let tasks = read("tasks/index.html");239 + assert!(!tasks.contains("<table"), "tasks page has no <table>");240 + // 200-char titles render in full (wrapped, not cut) inside title cells:241 + // the cell class is the no-page-widen hook (overflow-wrap:anywhere),242 + // and the # column keeps its narrow marker class.243 + for page in ["issues/index.html", "pulls/index.html"] {244 + let html = read(page);245 + assert!(246 + html.contains("<td class=\"title\">"),247 + "{page} marks title cells"248 + );249 + assert!(250 + html.contains(&long_title),251 + "{page} keeps the full long title"252 + );253 + assert!(html.contains("<td class=\"num\">"), "{page} keeps # narrow");254 + }255 + let commits = read("commits.html");256 + assert!(257 + commits.contains("<td class=\"sha\">"),258 + "commits marks sha cells"259 + );260 + let releases = read("releases/index.html");261 + assert!(262 + releases.contains("<td class=\"title\">"),263 + "releases marks title cells"264 + );265 + // Labels from the fixture still list on the issues index.266 + assert!(267 + read("issues/index.html").contains("kind:scroll-test"),268 + "labels listed"269 + );270 + // CSS side: title/sha cells wrap anywhere, # column stays narrow.271 + let css = css();272 + for needle in [273 + "td.title",274 + "td.sha",275 + "td.num",276 + "overflow-wrap:anywhere",277 + "white-space:nowrap",278 + ] {279 + assert!(css.contains(needle), "CSS mentions {needle}");280 + }281 +}282 +283 +#[test]284 +fn zero_bare_tables_in_sources() {285 + // Audit EVERY template (m6, f0rge issue #17): no `<table>` may open286 + // outside a `.table-scroll` container. All tables are emitted from287 + // src/*.rs render fns (markdown never emits tables), so grep the288 + // sources: each line opening a table must carry the scroll wrapper.289 + let mut checked = 0;290 + let mut stack = vec![manifest().join("src")];291 + while let Some(d) = stack.pop() {292 + for e in std::fs::read_dir(&d).expect("read src") {293 + let e = e.expect("entry");294 + let p = e.path();295 + if p.is_dir() {296 + stack.push(p);297 + continue;298 + }299 + if p.extension().is_none_or(|x| x != "rs") {300 + continue;301 + }302 + let text = std::fs::read_to_string(&p).expect("read source");303 + for (i, line) in text.lines().enumerate() {304 + if line.contains("<table") {305 + checked += 1;306 + assert!(307 + line.contains("table-scroll"),308 + "{}:{} opens a bare <table>",309 + p.display(),310 + i + 1311 + );312 + }313 + }314 + }315 + }316 + assert!(checked >= 5, "audit saw all list tables, got {checked}");317 +}318 -- 319 2.55.0320