m6/truncate: ellipsis + title attrs on all tables

97f97d1gl0w

all commits

Message

m6/truncate: ellipsis + title attrs on all tables

Diff

src/build.rs+18 −7
diff --git a/src/build.rs b/src/build.rsindex ffd8e86..20ca6f5 100644--- a/src/build.rs+++ b/src/build.rs@@ -584,8 +584,9 @@ pub fn file_table_html(584584     );585585     if let Some((href, label)) = parent {586586         out.push_str(&format!(587-            "<tr class=\"parent\"><td data-label=\"Name\"><a href=\"{}\">{}</a></td>\588-             <td data-label=\"Last commit\"></td><td data-label=\"Age\"></td></tr>\n",587+            "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\588+             <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",589+            esc(label),589590             esc(href),590591             esc(label)591592         ));@@ -600,20 +601,26 @@ pub fn file_table_html(600601         let msg_short = truncate_msg(&msg);601602         if e.is_dir {602603             out.push_str(&format!(603-                "<tr class=\"dir\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}/</a></td>\604-                 <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",604+                "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\605+                 <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",606+                esc(&e.name),605607                 href_esc(&full),606608                 esc(&e.name),609+                esc(&msg),607610                 esc(&msg_short),611+                esc(&age),608612                 esc(&age)609613             ));610614         } else {611615             out.push_str(&format!(612-                "<tr class=\"file\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}</a></td>\613-                 <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",616+                "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\617+                 <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",618+                esc(&e.name),614619                 href_esc(&full),615620                 esc(&e.name),621+                esc(&msg),616622                 esc(&msg_short),623+                esc(&age),617624                 esc(&age)618625             ));619626         }@@ -1717,7 +1724,11 @@ mod tests {17171724             "breadcrumb kept on subdir"17181725         );17191726         // Message + age columns exist even when history is empty-ish.1720-        assert!(sub.contains("<td class=\"title\""), "message cell");1727+        assert!(1728+            sub.contains("<td class=\"title trunc trunc-message\""),1729+            "message cell trunc"1730+        );1731+        assert!(sub.contains("title=\""), "title attrs kept");17211732     }17221733 17231734     /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.
src/commits.rs+14 −3
diff --git a/src/commits.rs b/src/commits.rsindex 60566f2..2324ee1 100644--- a/src/commits.rs+++ b/src/commits.rs@@ -43,11 +43,15 @@ pub fn render_loaded(4343         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");4444         for c in log {4545             body.push_str(&format!(46-                "<tr><td class=\"sha\" data-label=\"sha\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title\" data-label=\"subject\">{}</td><td data-label=\"author\">{}</td><td data-label=\"date\">{}</td></tr>\n",46+                "<tr><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",47+                esc(&c.sha),4748                 esc(&c.sha),4849                 esc(short_sha(&c.sha)),4950                 esc(&c.subject),51+                esc(&c.subject),5052                 esc(&c.author),53+                esc(&c.author),54+                esc(&c.date),5155                 esc(&c.date),5256             ));5357         }@@ -359,8 +363,15 @@ mod tests {359363             list.contains("<div class=\"table-scroll\"><table>"),360364             "wrapper"361365         );362-        assert!(list.contains("<td class=\"title\""), "title cell");363-        assert!(list.contains("<td class=\"sha\""), "sha cell");366+        assert!(367+            list.contains("<td class=\"title trunc trunc-subject\""),368+            "title cell trunc"369+        );370+        assert!(371+            list.contains("<td class=\"sha trunc trunc-sha\""),372+            "sha cell trunc"373+        );374+        assert!(list.contains("title=\""), "title attrs kept");364375         assert!(list.contains(&long_subject), "long subject kept");365376         let sha = shas(&dir)[0].clone();366377         assert!(
src/forge.rs+16 −8
diff --git a/src/forge.rs b/src/forge.rsindex ce85610..ea146b8 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -1001,7 +1001,7 @@ fn render_issues_index(10011001         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");10021002         for issue in &data.issues {10031003             body.push_str(&format!(1004-                "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{id}.html\">{title}</a></td><td data-label=\"state\">{state}</td><td data-label=\"comments\">{comments}</td><td data-label=\"created\">{created}</td></tr>\n",1004+                "<tr><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",10051005                 n = issue.number,10061006                 id = esc(&issue.id),10071007                 title = esc(&issue.title),@@ -1062,13 +1062,17 @@ fn render_pulls_index(10621062         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");10631063         for pr in prs {10641064             let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1065+            let lanes_plain = format!("{}→{}", pr.head, pr.base);10651066             let draft = if pr.draft { " draft" } else { "" };1067+            let full_title = format!("{}{}", pr.title, draft);10661068             body.push_str(&format!(1067-                "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td data-label=\"state\">{state}</td><td data-label=\"head→base\"><code>{lanes}</code></td><td data-label=\"created\">{created}</td></tr>\n",1069+                "<tr><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code>{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",10681070                 n = pr.number,10691071                 id = esc(&pr.id),10701072                 title = esc(&pr.title),1073+                full = esc(&full_title),10711074                 state = esc(&pr.state),1075+                lanes_title = esc(&lanes_plain),10721076                 created = esc(&pr.created),10731077             ));10741078         }@@ -1117,7 +1121,7 @@ fn render_tasks_index(11171121                         meta.push_str(&format!(" · closed {}", esc(c)));11181122                     }11191123                     body.push_str(&format!(1120-                        "<li class=\"task\">\n<p><strong>{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1124+                        "<li class=\"task\">\n<p><strong class=\"trunc trunc-title\" title=\"{title}\">{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",11211125                         title = esc(&t.title),11221126                         assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(11231127                             "<p class=\"muted\">assignee: <code>{}</code></p>\n",@@ -1175,10 +1179,10 @@ fn render_ci_index(11751179                 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");11761180                 for s in &run.steps {11771181                     body.push_str(&format!(1178-                        "<tr><td data-label=\"step\"><code>{}</code></td><td data-label=\"exit\">{}</td><td data-label=\"duration_ms\">{}</td></tr>\n",1179-                        esc(&s.name),1180-                        s.exit,1181-                        s.duration_ms,1182+                        "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration_ms\" title=\"{dur}\">{dur}</td></tr>\n",1183+                        name = esc(&s.name),1184+                        exit = s.exit,1185+                        dur = s.duration_ms,11821186                     ));11831187                 }11841188                 body.push_str("</tbody>\n</table></div>\n");@@ -1742,7 +1746,11 @@ mod tests {17421746                 html.contains("<div class=\"table-scroll\"><table>"),17431747                 "{page}: wrapper"17441748             );1745-            assert!(html.contains("<td class=\"title\""), "{page}: title cell");1749+            assert!(1750+                html.contains("<td class=\"title trunc trunc-title\""),1751+                "{page}: title cell trunc"1752+            );1753+            assert!(html.contains("title=\""), "{page}: title attrs");17461754             assert!(html.contains(&long_title), "{page}: long title kept");17471755         }17481756         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");
src/releases.rs+6 −2
diff --git a/src/releases.rs b/src/releases.rsindex 507c24e..68c050b 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -398,7 +398,7 @@ pub fn render_loaded(398398         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");399399         for rel in releases {400400             body.push_str(&format!(401-                "<tr><td data-label=\"version\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title\" data-label=\"title\">{t}</td><td data-label=\"created\">{c}</td><td data-label=\"artifacts\">{n}</td></tr>\n",401+                "<tr><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",402402                 v = esc(&rel.version),403403                 ve = esc(&rel.version),404404                 t = esc(rel.display_title()),@@ -512,7 +512,11 @@ mod tests {512512             index.contains("<div class=\"table-scroll\"><table>"),513513             "wrapper"514514         );515-        assert!(index.contains("<td class=\"title\""), "title cell");515+        assert!(516+            index.contains("<td class=\"title trunc trunc-title\""),517+            "title cell trunc"518+        );519+        assert!(index.contains("title=\""), "title attrs kept");516520         assert!(index.contains(&long_title), "long title kept");517521     }518522 
static/style.css+13 −1
diff --git a/static/style.css b/static/style.cssindex 386ad62..17175e3 100644--- a/static/style.css+++ b/static/style.css@@ -169,10 +169,21 @@ a.ln:hover,a.ln:focus{text-decoration:underline}169169 /* List tables scroll, never squash (m6/table-scroll, f0rge #30): natural170170    width inside .table-scroll; cells nowrap except title cells which wrap171171    with a floor so rows scroll horizontally. Hover tint via var. */172-.table-scroll table{width:max-content;min-width:100%}172+.table-scroll table{width:max-content;min-width:100%;max-width:100%}173173 .table-scroll th,.table-scroll td{white-space:nowrap}174174 .table-scroll td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}175175 .table-scroll tbody tr:hover td{background:var(--bg-th)}176+/* Truncate all table fields (m6/truncate, f0rge #42): ellipsis + nowrap177+   with per-kind max-widths so desktop tables fit the 1100px container178+   without page-level h-scroll. Full text stays in title= attrs.179+   Later + equal specificity beats td.title wrap above on desktop. */180+.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}181+.table-scroll td.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}182+.table-scroll td.trunc-subject,.trunc-subject{max-width:40ch}183+.table-scroll td.trunc-author,.trunc-author{max-width:16ch}184+.table-scroll td.trunc-sha,.trunc-sha{max-width:10ch}185+.table-scroll td.trunc-message,.trunc-message{max-width:72ch}186+.table-scroll td.trunc-title,.trunc-title{max-width:60ch}176187 /* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +177188    button share one flex row; wrap allowed on 360px but stays adjacent. */178189 .page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}@@ -225,4 +236,5 @@ details.clonebox:not([open]){padding-bottom:.25rem}225236   .table-scroll td.num,.table-scroll td.sha{width:auto}226237   .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}227238   .table-scroll tbody tr:hover td{background:none}239+  .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}228240 }
tests/forge_readers.rs+5 −5
diff --git a/tests/forge_readers.rs b/tests/forge_readers.rsindex b3aa723..c03348b 100644--- a/tests/forge_readers.rs+++ b/tests/forge_readers.rs@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {224224 225225     // --- issues index: 2 rendered rows (broken one skipped) ---226226     let issues = read("issues/index.html");227-    let rows = issues.matches("<tr><td class=\"num\"").count();227+    let rows = issues.matches("<tr><td class=\"num").count();228228     assert_eq!(rows, 2, "rendered issues match file-count minus broken");229229     assert!(!issues.contains("No title here"), "broken issue skipped");230230     // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.231231     assert!(232232         issues.contains(&format!(233-            "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"233+            "<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">"234234         )),235235         "issue #1 -> older uuid"236236     );237237     assert!(238238         issues.contains(&format!(239-            "<td class=\"num\" data-label=\"#\">2</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"239+            "<td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">"240240         )),241241         "issue #2 -> newer uuid"242242     );@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {263263     // (4): pulls — same shape + head/base/draft/ci + patch <pre>.264264     let pulls = read("pulls/index.html");265265     assert_eq!(266-        pulls.matches("<tr><td class=\"num\"").count(),266+        pulls.matches("<tr><td class=\"num").count(),267267         1,268268         "prs match file-count"269269     );270270     assert!(271271         pulls.contains(&format!(272-            "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"272+            "<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">"273273         )),274274         "pr #1 -> uuid"275275     );
tests/releases_api.rs+3 −5
diff --git a/tests/releases_api.rs b/tests/releases_api.rsindex ea32b2c..0c7e472 100644--- a/tests/releases_api.rs+++ b/tests/releases_api.rs@@ -154,14 +154,12 @@ fn releases_artifacts_api_agents_feed() {154154 155155     // --- api counts == html counts (same struct pass) ---156156     let issues_html = read("issues/index.html")157-        .matches("<tr><td class=\"num\"")157+        .matches("<tr><td class=\"num")158158         .count();159159     let pulls_html = read("pulls/index.html")160-        .matches("<tr><td class=\"num\"")161-        .count();162-    let commits_html = read("commits.html")163-        .matches("<tr><td class=\"sha\"")160+        .matches("<tr><td class=\"num")164161         .count();162+    let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();165163     let releases_html = read("releases/index.html").matches("<tr><td").count();166164     let wiki_html = read("wiki/index.html")167165         .matches("<li><a href=\"/wiki/")
tests/responsive.rs+11 −7
diff --git a/tests/responsive.rs b/tests/responsive.rsindex f0c4b2d..85b664e 100644--- a/tests/responsive.rs+++ b/tests/responsive.rs@@ -377,24 +377,28 @@ fn forge_list_tables_scroll_and_wrap_long_titles() {377377     for page in ["issues/index.html", "pulls/index.html"] {378378         let html = read(page);379379         assert!(380-            html.contains("<td class=\"title\""),381-            "{page} marks title cells"380+            html.contains("<td class=\"title trunc trunc-title\""),381+            "{page} marks title cells trunc"382382         );383383         assert!(384384             html.contains(&long_title),385385             "{page} keeps the full long title"386386         );387-        assert!(html.contains("<td class=\"num\""), "{page} keeps # narrow");387+        assert!(388+            html.contains("<td class=\"num trunc\""),389+            "{page} keeps # narrow trunc"390+        );391+        assert!(html.contains("title=\""), "{page} keeps title attrs");388392     }389393     let commits = read("commits.html");390394     assert!(391-        commits.contains("<td class=\"sha\""),392-        "commits marks sha cells"395+        commits.contains("<td class=\"sha trunc trunc-sha\""),396+        "commits marks sha cells trunc"393397     );394398     let releases = read("releases/index.html");395399     assert!(396-        releases.contains("<td class=\"title\""),397-        "releases marks title cells"400+        releases.contains("<td class=\"title trunc trunc-title\""),401+        "releases marks title cells trunc"398402     );399403     // Labels from the fixture still list on the issues index.400404     assert!(
tests/truncate.rs+254 −0
diff --git a/tests/truncate.rs b/tests/truncate.rsnew file mode 100644index 0000000..12d9004--- /dev/null+++ b/tests/truncate.rs@@ -0,0 +1,254 @@1+//! Truncate all table fields (m6/truncate, f0rge #42):2+//! every table cell carries `.trunc` + `title=`, desktop fits 1100px.3+4+use std::path::{Path, PathBuf};5+use std::process::Command;6+7+fn bin() -> &'static str {8+    env!("CARGO_BIN_EXE_hu13")9+}10+11+fn manifest() -> PathBuf {12+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))13+}14+15+fn fresh(prefix: &str) -> PathBuf {16+    let dir = std::env::temp_dir().join(format!(17+        "hu13-truncate-{prefix}-{}-{}",18+        std::process::id(),19+        std::time::SystemTime::now()20+            .duration_since(std::time::UNIX_EPOCH)21+            .map(|d| d.as_nanos())22+            .unwrap_or(0)23+    ));24+    std::fs::create_dir_all(&dir).unwrap();25+    dir26+}27+28+fn git(repo: &Path, args: &[&str]) {29+    let st = Command::new("git")30+        .arg("-C")31+        .arg(repo)32+        .args(args)33+        .status()34+        .expect("spawn git");35+    assert!(st.success(), "git {args:?} failed");36+}37+38+fn forge_write(repo: &Path, rel: &str, content: &str) {39+    let dest = repo.join(".forge").join(rel);40+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();41+    std::fs::write(&dest, content).unwrap();42+}43+44+fn css() -> String {45+    std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")46+}47+48+/// Evil long fixture: unbroken 300-char strings + HTML metachars in every49+/// table (commits subject/author, issues/pulls/tasks/releases titles,50+/// file-table message, CI step name). Returns (dist, evil_title, evil_subject).51+fn build_evil_fixture() -> (PathBuf, String, String) {52+    let repo = fresh("repo");53+    git(&repo, &["init", "-b", "main"]);54+    git(&repo, &["config", "user.name", "hu13-test"]);55+    git(&repo, &["config", "user.email", "[email protected]"]);56+    git(&repo, &["config", "commit.gpgsign", "false"]);57+    // Evil long subject: unbroken + <script> + quotes (tests esc in cell + title=).58+    let evil_subject = format!(59+        "S{} <script>alert(1)</script> \"quoted\" & more",60+        "x".repeat(260)61+    );62+    // Long evil author via --author on one commit.63+    let evil_author = format!("{} <[email protected]>", "A".repeat(120));64+    std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap();65+    std::fs::write(repo.join("notes.txt"), "hello\n").unwrap();66+    git(&repo, &["add", "-A"]);67+    git(68+        &repo,69+        &[70+            "commit",71+            "-m",72+            &evil_subject,73+            &format!("--author={evil_author}"),74+        ],75+    );76+    std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap();77+    git(&repo, &["add", "-A"]);78+    git(&repo, &["commit", "-m", "second commit"]);79+80+    let evil_title = format!("T{} <b>bold</b> \"q\" & tail", "y".repeat(260));81+    forge_write(82+        &repo,83+        "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",84+        &format!(85+            "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n"86+        ),87+    );88+    forge_write(89+        &repo,90+        "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",91+        &format!(92+            "---\nid: bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb\ntitle: {evil_title}\nstate: open\nbase: main\nhead: evil-branch-name-that-is-way-too-long-for-a-table-cell-to-fit\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n"93+        ),94+    );95+    forge_write(96+        &repo,97+        "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",98+        &format!(99+            "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n"100+        ),101+    );102+    forge_write(&repo, "ci/ci.env", "runner: local\n");103+    let long_step = format!("{}_step.sh", "s".repeat(100));104+    forge_write(105+        &repo,106+        &format!("ci/steps/{long_step}"),107+        "#!/bin/sh\nexit 0\n",108+    );109+    forge_write(110+        &repo,111+        "ci/runs/2026-09-10T00-00-00Z/summary.md",112+        &format!(113+            "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: {long_step}\n    exit: 0\n    duration_ms: 3\n---\nlog\n"114+        ),115+    );116+    forge_write(117+        &repo,118+        "releases/v9.9.md",119+        &format!(120+            "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n"121+        ),122+    );123+    git(&repo, &["add", "-A"]);124+    git(&repo, &["commit", "-m", "forge evil fixture"]);125+    let out = fresh("out");126+    let st = Command::new(bin())127+        .arg("build")128+        .arg(&repo)129+        .arg("-o")130+        .arg(&out)131+        .status()132+        .expect("spawn hu13 build");133+    assert!(st.success(), "evil fixture build exits 0");134+    (out, evil_title, evil_subject)135+}136+137+fn assert_every_td_truncated(html: &str, page: &str) {138+    let parts: Vec<&str> = html.split("<td").skip(1).collect();139+    assert!(!parts.is_empty(), "{page} has cells");140+    for (i, p) in parts.iter().enumerate() {141+        let head = p.split('>').next().unwrap_or("");142+        assert!(143+            head.contains("trunc"),144+            "{page} td #{i} carries .trunc (got {head:?})"145+        );146+        assert!(147+            head.contains("title="),148+            "{page} td #{i} carries title= (got {head:?})"149+        );150+        assert!(151+            head.contains("data-label="),152+            "{page} td #{i} keeps data-label (got {head:?})"153+        );154+    }155+}156+157+#[test]158+fn trunc_css_utilities_and_widths() {159+    let css = css();160+    for needle in [161+        ".trunc",162+        "text-overflow:ellipsis",163+        "40ch",164+        "16ch",165+        "10ch",166+        "72ch",167+        "60ch",168+    ] {169+        assert!(css.contains(needle), "CSS mentions {needle}");170+    }171+    // Desktop tables capped at the container (fit 1100px, no page h-scroll).172+    assert!(css.contains("max-width:100%"), "tables capped at container");173+    assert!(174+        css.contains("overflow-x:clip") || css.contains("overflow-x:auto"),175+        "no page h-scroll"176+    );177+    // Narrow keeps card reflow (trunc disabled, scroll off).178+    assert!(179+        css.contains("@media (max-width:767px)"),180+        "narrow query kept"181+    );182+}183+184+#[test]185+fn every_table_cell_truncates_with_title() {186+    let (out, evil_title, evil_subject) = build_evil_fixture();187+    let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);188+    // Full text is never lost: escaped long strings stay in the DOM and in title=.189+    for page in [190+        "commits.html",191+        "issues/index.html",192+        "pulls/index.html",193+        "tasks/index.html",194+        "releases/index.html",195+        "ci/index.html",196+        "index.html",197+    ] {198+        let html = read(page);199+        assert!(200+            !html.contains("<script>alert(1)</script>"),201+            "{page} escapes evil HTML"202+        );203+        assert!(!html.contains("<b>bold</b>"), "{page} escapes evil tags");204+    }205+    // Evil subject + title survive escaped (cell text + title attr).206+    let commits = read("commits.html");207+    assert!(commits.contains("&lt;script&gt;"), "commits escape kept");208+    assert!(commits.contains("trunc-sha"), "commits sha width");209+    assert!(commits.contains("trunc-subject"), "commits subject width");210+    assert!(commits.contains("trunc-author"), "commits author width");211+    // Per-table trunc asserts + title attrs.212+    for page in [213+        "commits.html",214+        "issues/index.html",215+        "pulls/index.html",216+        "releases/index.html",217+        "ci/index.html",218+        "index.html",219+    ] {220+        assert_every_td_truncated(&read(page), page);221+    }222+    // Kind-specific widths per table.223+    assert!(224+        read("issues/index.html").contains("trunc-title"),225+        "issues title 60ch"226+    );227+    assert!(228+        read("pulls/index.html").contains("trunc-title"),229+        "pulls title 60ch"230+    );231+    assert!(232+        read("releases/index.html").contains("trunc-title"),233+        "releases title 60ch"234+    );235+    assert!(236+        read("index.html").contains("trunc-message"),237+        "file-table message 72ch"238+    );239+    // Tasks list uses trunc + title on titles (no table by design).240+    let tasks = read("tasks/index.html");241+    assert!(!tasks.contains("<table"), "tasks stays list-only");242+    assert!(tasks.contains("trunc-title"), "tasks titles truncate");243+    assert!(tasks.contains("title="), "tasks titles keep full text");244+    // Long content kept (CSS truncates visually, nothing cut in HTML).245+    assert!(246+        read("issues/index.html").contains(&"y".repeat(100)),247+        "long issue title kept"248+    );249+    assert!(250+        commits.contains(&"x".repeat(100)),251+        "long commit subject kept"252+    );253+    let _ = (evil_title, evil_subject);254+}