diff --git a/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/patch.diff b/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/patch.diffnew file mode 100644index 0000000..79e17c1--- /dev/null+++ b/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/patch.diff@@ -0,0 +1,613 @@1+From 954e3b6e0d141266b2941a63a6a07a80ab96eff7 Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 09:35:20 +01004+Subject: [PATCH] m6/truncate: ellipsis + title attrs on all tables5+6+---7+ src/build.rs | 25 ++--8+ src/commits.rs | 17 ++-9+ src/forge.rs | 24 ++--10+ src/releases.rs | 8 +-11+ static/style.css | 14 ++-12+ tests/forge_readers.rs | 10 +-13+ tests/releases_api.rs | 8 +-14+ tests/responsive.rs | 18 +--15+ tests/truncate.rs | 254 +++++++++++++++++++++++++++++++++++++++++16+ 9 files changed, 340 insertions(+), 38 deletions(-)17+ create mode 100644 tests/truncate.rs18+19+diff --git a/src/build.rs b/src/build.rs20+index 5d176ba..bc0a10b 10064421+--- a/src/build.rs22++++ b/src/build.rs23+@@ -584,8 +584,9 @@ pub fn file_table_html(24+ );25+ if let Some((href, label)) = parent {26+ out.push_str(&format!(27+- "<tr class=\"parent\"><td data-label=\"Name\"><a href=\"{}\">{}</a></td>\28+- <td data-label=\"Last commit\"></td><td data-label=\"Age\"></td></tr>\n",29++ "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\30++ <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",31++ esc(label),32+ esc(href),33+ esc(label)34+ ));35+@@ -600,20 +601,26 @@ pub fn file_table_html(36+ let msg_short = truncate_msg(&msg);37+ if e.is_dir {38+ out.push_str(&format!(39+- "<tr class=\"dir\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}/</a></td>\40+- <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",41++ "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\42++ <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",43++ esc(&e.name),44+ href_esc(&full),45+ esc(&e.name),46++ esc(&msg),47+ esc(&msg_short),48++ esc(&age),49+ esc(&age)50+ ));51+ } else {52+ out.push_str(&format!(53+- "<tr class=\"file\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}</a></td>\54+- <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",55++ "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\56++ <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",57++ esc(&e.name),58+ href_esc(&full),59+ esc(&e.name),60++ esc(&msg),61+ esc(&msg_short),62++ esc(&age),63+ esc(&age)64+ ));65+ }66+@@ -1717,7 +1724,11 @@ mod tests {67+ "breadcrumb kept on subdir"68+ );69+ // Message + age columns exist even when history is empty-ish.70+- assert!(sub.contains("<td class=\"title\""), "message cell");71++ assert!(72++ sub.contains("<td class=\"title trunc trunc-message\""),73++ "message cell trunc"74++ );75++ assert!(sub.contains("title=\""), "title attrs kept");76+ }77+ 78+ /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.79+diff --git a/src/commits.rs b/src/commits.rs80+index 60566f2..2324ee1 10064481+--- a/src/commits.rs82++++ b/src/commits.rs83+@@ -43,11 +43,15 @@ pub fn render_loaded(84+ 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");85+ for c in log {86+ body.push_str(&format!(87+- "<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",88++ "<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",89++ esc(&c.sha),90+ esc(&c.sha),91+ esc(short_sha(&c.sha)),92+ esc(&c.subject),93++ esc(&c.subject),94+ esc(&c.author),95++ esc(&c.author),96++ esc(&c.date),97+ esc(&c.date),98+ ));99+ }100+@@ -359,8 +363,15 @@ mod tests {101+ list.contains("<div class=\"table-scroll\"><table>"),102+ "wrapper"103+ );104+- assert!(list.contains("<td class=\"title\""), "title cell");105+- assert!(list.contains("<td class=\"sha\""), "sha cell");106++ assert!(107++ list.contains("<td class=\"title trunc trunc-subject\""),108++ "title cell trunc"109++ );110++ assert!(111++ list.contains("<td class=\"sha trunc trunc-sha\""),112++ "sha cell trunc"113++ );114++ assert!(list.contains("title=\""), "title attrs kept");115+ assert!(list.contains(&long_subject), "long subject kept");116+ let sha = shas(&dir)[0].clone();117+ assert!(118+diff --git a/src/forge.rs b/src/forge.rs119+index 040b552..40aa052 100644120+--- a/src/forge.rs121++++ b/src/forge.rs122+@@ -1001,7 +1001,7 @@ fn render_issues_index(123+ 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");124+ for issue in &data.issues {125+ body.push_str(&format!(126+- "<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",127++ "<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",128+ n = issue.number,129+ id = esc(&issue.id),130+ title = esc(&issue.title),131+@@ -1062,13 +1062,17 @@ fn render_pulls_index(132+ 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");133+ for pr in prs {134+ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));135++ let lanes_plain = format!("{}→{}", pr.head, pr.base);136+ let draft = if pr.draft { " draft" } else { "" };137++ let full_title = format!("{}{}", pr.title, draft);138+ body.push_str(&format!(139+- "<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",140++ "<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",141+ n = pr.number,142+ id = esc(&pr.id),143+ title = esc(&pr.title),144++ full = esc(&full_title),145+ state = esc(&pr.state),146++ lanes_title = esc(&lanes_plain),147+ created = esc(&pr.created),148+ ));149+ }150+@@ -1117,7 +1121,7 @@ fn render_tasks_index(151+ meta.push_str(&format!(" · closed {}", esc(c)));152+ }153+ body.push_str(&format!(154+- "<li class=\"task\">\n<p><strong>{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",155++ "<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",156+ title = esc(&t.title),157+ assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(158+ "<p class=\"muted\">assignee: <code>{}</code></p>\n",159+@@ -1175,10 +1179,10 @@ fn render_ci_index(160+ 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");161+ for s in &run.steps {162+ body.push_str(&format!(163+- "<tr><td data-label=\"step\"><code>{}</code></td><td data-label=\"exit\">{}</td><td data-label=\"duration_ms\">{}</td></tr>\n",164+- esc(&s.name),165+- s.exit,166+- s.duration_ms,167++ "<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",168++ name = esc(&s.name),169++ exit = s.exit,170++ dur = s.duration_ms,171+ ));172+ }173+ body.push_str("</tbody>\n</table></div>\n");174+@@ -1742,7 +1746,11 @@ mod tests {175+ html.contains("<div class=\"table-scroll\"><table>"),176+ "{page}: wrapper"177+ );178+- assert!(html.contains("<td class=\"title\""), "{page}: title cell");179++ assert!(180++ html.contains("<td class=\"title trunc trunc-title\""),181++ "{page}: title cell trunc"182++ );183++ assert!(html.contains("title=\""), "{page}: title attrs");184+ assert!(html.contains(&long_title), "{page}: long title kept");185+ }186+ let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");187+diff --git a/src/releases.rs b/src/releases.rs188+index 507c24e..68c050b 100644189+--- a/src/releases.rs190++++ b/src/releases.rs191+@@ -398,7 +398,7 @@ pub fn render_loaded(192+ 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");193+ for rel in releases {194+ body.push_str(&format!(195+- "<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",196++ "<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",197+ v = esc(&rel.version),198+ ve = esc(&rel.version),199+ t = esc(rel.display_title()),200+@@ -512,7 +512,11 @@ mod tests {201+ index.contains("<div class=\"table-scroll\"><table>"),202+ "wrapper"203+ );204+- assert!(index.contains("<td class=\"title\""), "title cell");205++ assert!(206++ index.contains("<td class=\"title trunc trunc-title\""),207++ "title cell trunc"208++ );209++ assert!(index.contains("title=\""), "title attrs kept");210+ assert!(index.contains(&long_title), "long title kept");211+ }212+ 213+diff --git a/static/style.css b/static/style.css214+index fa1b70d..f4d02e6 100644215+--- a/static/style.css216++++ b/static/style.css217+@@ -169,10 +169,21 @@ a.ln:hover,a.ln:focus{text-decoration:underline}218+ /* List tables scroll, never squash (m6/table-scroll, f0rge #30): natural219+ width inside .table-scroll; cells nowrap except title cells which wrap220+ with a floor so rows scroll horizontally. Hover tint via var. */221+-.table-scroll table{width:max-content;min-width:100%}222++.table-scroll table{width:max-content;min-width:100%;max-width:100%}223+ .table-scroll th,.table-scroll td{white-space:nowrap}224+ .table-scroll td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}225+ .table-scroll tbody tr:hover td{background:var(--bg-th)}226++/* Truncate all table fields (m6/truncate, f0rge #42): ellipsis + nowrap227++ with per-kind max-widths so desktop tables fit the 1100px container228++ without page-level h-scroll. Full text stays in title= attrs.229++ Later + equal specificity beats td.title wrap above on desktop. */230++.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}231++.table-scroll td.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}232++.table-scroll td.trunc-subject,.trunc-subject{max-width:40ch}233++.table-scroll td.trunc-author,.trunc-author{max-width:16ch}234++.table-scroll td.trunc-sha,.trunc-sha{max-width:10ch}235++.table-scroll td.trunc-message,.trunc-message{max-width:72ch}236++.table-scroll td.trunc-title,.trunc-title{max-width:60ch}237+ /* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +238+ button share one flex row; wrap allowed on 360px but stays adjacent. */239+ .page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}240+@@ -212,4 +223,5 @@ details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-off241+ .table-scroll td.num,.table-scroll td.sha{width:auto}242+ .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}243+ .table-scroll tbody tr:hover td{background:none}244++ .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}245+ }246+diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs247+index b3aa723..c03348b 100644248+--- a/tests/forge_readers.rs249++++ b/tests/forge_readers.rs250+@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {251+ 252+ // --- issues index: 2 rendered rows (broken one skipped) ---253+ let issues = read("issues/index.html");254+- let rows = issues.matches("<tr><td class=\"num\"").count();255++ let rows = issues.matches("<tr><td class=\"num").count();256+ assert_eq!(rows, 2, "rendered issues match file-count minus broken");257+ assert!(!issues.contains("No title here"), "broken issue skipped");258+ // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.259+ assert!(260+ issues.contains(&format!(261+- "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"262++ "<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\">"263+ )),264+ "issue #1 -> older uuid"265+ );266+ assert!(267+ issues.contains(&format!(268+- "<td class=\"num\" data-label=\"#\">2</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"269++ "<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\">"270+ )),271+ "issue #2 -> newer uuid"272+ );273+@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {274+ // (4): pulls — same shape + head/base/draft/ci + patch <pre>.275+ let pulls = read("pulls/index.html");276+ assert_eq!(277+- pulls.matches("<tr><td class=\"num\"").count(),278++ pulls.matches("<tr><td class=\"num").count(),279+ 1,280+ "prs match file-count"281+ );282+ assert!(283+ pulls.contains(&format!(284+- "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"285++ "<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\">"286+ )),287+ "pr #1 -> uuid"288+ );289+diff --git a/tests/releases_api.rs b/tests/releases_api.rs290+index ea32b2c..0c7e472 100644291+--- a/tests/releases_api.rs292++++ b/tests/releases_api.rs293+@@ -154,14 +154,12 @@ fn releases_artifacts_api_agents_feed() {294+ 295+ // --- api counts == html counts (same struct pass) ---296+ let issues_html = read("issues/index.html")297+- .matches("<tr><td class=\"num\"")298++ .matches("<tr><td class=\"num")299+ .count();300+ let pulls_html = read("pulls/index.html")301+- .matches("<tr><td class=\"num\"")302+- .count();303+- let commits_html = read("commits.html")304+- .matches("<tr><td class=\"sha\"")305++ .matches("<tr><td class=\"num")306+ .count();307++ let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();308+ let releases_html = read("releases/index.html").matches("<tr><td").count();309+ let wiki_html = read("wiki/index.html")310+ .matches("<li><a href=\"/wiki/")311+diff --git a/tests/responsive.rs b/tests/responsive.rs312+index f0c4b2d..85b664e 100644313+--- a/tests/responsive.rs314++++ b/tests/responsive.rs315+@@ -377,24 +377,28 @@ fn forge_list_tables_scroll_and_wrap_long_titles() {316+ for page in ["issues/index.html", "pulls/index.html"] {317+ let html = read(page);318+ assert!(319+- html.contains("<td class=\"title\""),320+- "{page} marks title cells"321++ html.contains("<td class=\"title trunc trunc-title\""),322++ "{page} marks title cells trunc"323+ );324+ assert!(325+ html.contains(&long_title),326+ "{page} keeps the full long title"327+ );328+- assert!(html.contains("<td class=\"num\""), "{page} keeps # narrow");329++ assert!(330++ html.contains("<td class=\"num trunc\""),331++ "{page} keeps # narrow trunc"332++ );333++ assert!(html.contains("title=\""), "{page} keeps title attrs");334+ }335+ let commits = read("commits.html");336+ assert!(337+- commits.contains("<td class=\"sha\""),338+- "commits marks sha cells"339++ commits.contains("<td class=\"sha trunc trunc-sha\""),340++ "commits marks sha cells trunc"341+ );342+ let releases = read("releases/index.html");343+ assert!(344+- releases.contains("<td class=\"title\""),345+- "releases marks title cells"346++ releases.contains("<td class=\"title trunc trunc-title\""),347++ "releases marks title cells trunc"348+ );349+ // Labels from the fixture still list on the issues index.350+ assert!(351+diff --git a/tests/truncate.rs b/tests/truncate.rs352+new file mode 100644353+index 0000000..12d9004354+--- /dev/null355++++ b/tests/truncate.rs356+@@ -0,0 +1,254 @@357++//! Truncate all table fields (m6/truncate, f0rge #42):358++//! every table cell carries `.trunc` + `title=`, desktop fits 1100px.359++360++use std::path::{Path, PathBuf};361++use std::process::Command;362++363++fn bin() -> &'static str {364++ env!("CARGO_BIN_EXE_hu13")365++}366++367++fn manifest() -> PathBuf {368++ PathBuf::from(env!("CARGO_MANIFEST_DIR"))369++}370++371++fn fresh(prefix: &str) -> PathBuf {372++ let dir = std::env::temp_dir().join(format!(373++ "hu13-truncate-{prefix}-{}-{}",374++ std::process::id(),375++ std::time::SystemTime::now()376++ .duration_since(std::time::UNIX_EPOCH)377++ .map(|d| d.as_nanos())378++ .unwrap_or(0)379++ ));380++ std::fs::create_dir_all(&dir).unwrap();381++ dir382++}383++384++fn git(repo: &Path, args: &[&str]) {385++ let st = Command::new("git")386++ .arg("-C")387++ .arg(repo)388++ .args(args)389++ .status()390++ .expect("spawn git");391++ assert!(st.success(), "git {args:?} failed");392++}393++394++fn forge_write(repo: &Path, rel: &str, content: &str) {395++ let dest = repo.join(".forge").join(rel);396++ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();397++ std::fs::write(&dest, content).unwrap();398++}399++400++fn css() -> String {401++ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")402++}403++404++/// Evil long fixture: unbroken 300-char strings + HTML metachars in every405++/// table (commits subject/author, issues/pulls/tasks/releases titles,406++/// file-table message, CI step name). Returns (dist, evil_title, evil_subject).407++fn build_evil_fixture() -> (PathBuf, String, String) {408++ let repo = fresh("repo");409++ git(&repo, &["init", "-b", "main"]);410++ git(&repo, &["config", "user.name", "hu13-test"]);411++ git(&repo, &["config", "user.email", "[email protected]"]);412++ git(&repo, &["config", "commit.gpgsign", "false"]);413++ // Evil long subject: unbroken + <script> + quotes (tests esc in cell + title=).414++ let evil_subject = format!(415++ "S{} <script>alert(1)</script> \"quoted\" & more",416++ "x".repeat(260)417++ );418++ // Long evil author via --author on one commit.419++ let evil_author = format!("{} <[email protected]>", "A".repeat(120));420++ std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap();421++ std::fs::write(repo.join("notes.txt"), "hello\n").unwrap();422++ git(&repo, &["add", "-A"]);423++ git(424++ &repo,425++ &[426++ "commit",427++ "-m",428++ &evil_subject,429++ &format!("--author={evil_author}"),430++ ],431++ );432++ std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap();433++ git(&repo, &["add", "-A"]);434++ git(&repo, &["commit", "-m", "second commit"]);435++436++ let evil_title = format!("T{} <b>bold</b> \"q\" & tail", "y".repeat(260));437++ forge_write(438++ &repo,439++ "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",440++ &format!(441++ "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n"442++ ),443++ );444++ forge_write(445++ &repo,446++ "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",447++ &format!(448++ "---\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"449++ ),450++ );451++ forge_write(452++ &repo,453++ "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",454++ &format!(455++ "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n"456++ ),457++ );458++ forge_write(&repo, "ci/ci.env", "runner: local\n");459++ let long_step = format!("{}_step.sh", "s".repeat(100));460++ forge_write(461++ &repo,462++ &format!("ci/steps/{long_step}"),463++ "#!/bin/sh\nexit 0\n",464++ );465++ forge_write(466++ &repo,467++ "ci/runs/2026-09-10T00-00-00Z/summary.md",468++ &format!(469++ "---\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"470++ ),471++ );472++ forge_write(473++ &repo,474++ "releases/v9.9.md",475++ &format!(476++ "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n"477++ ),478++ );479++ git(&repo, &["add", "-A"]);480++ git(&repo, &["commit", "-m", "forge evil fixture"]);481++ let out = fresh("out");482++ let st = Command::new(bin())483++ .arg("build")484++ .arg(&repo)485++ .arg("-o")486++ .arg(&out)487++ .status()488++ .expect("spawn hu13 build");489++ assert!(st.success(), "evil fixture build exits 0");490++ (out, evil_title, evil_subject)491++}492++493++fn assert_every_td_truncated(html: &str, page: &str) {494++ let parts: Vec<&str> = html.split("<td").skip(1).collect();495++ assert!(!parts.is_empty(), "{page} has cells");496++ for (i, p) in parts.iter().enumerate() {497++ let head = p.split('>').next().unwrap_or("");498++ assert!(499++ head.contains("trunc"),500++ "{page} td #{i} carries .trunc (got {head:?})"501++ );502++ assert!(503++ head.contains("title="),504++ "{page} td #{i} carries title= (got {head:?})"505++ );506++ assert!(507++ head.contains("data-label="),508++ "{page} td #{i} keeps data-label (got {head:?})"509++ );510++ }511++}512++513++#[test]514++fn trunc_css_utilities_and_widths() {515++ let css = css();516++ for needle in [517++ ".trunc",518++ "text-overflow:ellipsis",519++ "40ch",520++ "16ch",521++ "10ch",522++ "72ch",523++ "60ch",524++ ] {525++ assert!(css.contains(needle), "CSS mentions {needle}");526++ }527++ // Desktop tables capped at the container (fit 1100px, no page h-scroll).528++ assert!(css.contains("max-width:100%"), "tables capped at container");529++ assert!(530++ css.contains("overflow-x:clip") || css.contains("overflow-x:auto"),531++ "no page h-scroll"532++ );533++ // Narrow keeps card reflow (trunc disabled, scroll off).534++ assert!(535++ css.contains("@media (max-width:767px)"),536++ "narrow query kept"537++ );538++}539++540++#[test]541++fn every_table_cell_truncates_with_title() {542++ let (out, evil_title, evil_subject) = build_evil_fixture();543++ let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);544++ // Full text is never lost: escaped long strings stay in the DOM and in title=.545++ for page in [546++ "commits.html",547++ "issues/index.html",548++ "pulls/index.html",549++ "tasks/index.html",550++ "releases/index.html",551++ "ci/index.html",552++ "index.html",553++ ] {554++ let html = read(page);555++ assert!(556++ !html.contains("<script>alert(1)</script>"),557++ "{page} escapes evil HTML"558++ );559++ assert!(!html.contains("<b>bold</b>"), "{page} escapes evil tags");560++ }561++ // Evil subject + title survive escaped (cell text + title attr).562++ let commits = read("commits.html");563++ assert!(commits.contains("<script>"), "commits escape kept");564++ assert!(commits.contains("trunc-sha"), "commits sha width");565++ assert!(commits.contains("trunc-subject"), "commits subject width");566++ assert!(commits.contains("trunc-author"), "commits author width");567++ // Per-table trunc asserts + title attrs.568++ for page in [569++ "commits.html",570++ "issues/index.html",571++ "pulls/index.html",572++ "releases/index.html",573++ "ci/index.html",574++ "index.html",575++ ] {576++ assert_every_td_truncated(&read(page), page);577++ }578++ // Kind-specific widths per table.579++ assert!(580++ read("issues/index.html").contains("trunc-title"),581++ "issues title 60ch"582++ );583++ assert!(584++ read("pulls/index.html").contains("trunc-title"),585++ "pulls title 60ch"586++ );587++ assert!(588++ read("releases/index.html").contains("trunc-title"),589++ "releases title 60ch"590++ );591++ assert!(592++ read("index.html").contains("trunc-message"),593++ "file-table message 72ch"594++ );595++ // Tasks list uses trunc + title on titles (no table by design).596++ let tasks = read("tasks/index.html");597++ assert!(!tasks.contains("<table"), "tasks stays list-only");598++ assert!(tasks.contains("trunc-title"), "tasks titles truncate");599++ assert!(tasks.contains("title="), "tasks titles keep full text");600++ // Long content kept (CSS truncates visually, nothing cut in HTML).601++ assert!(602++ read("issues/index.html").contains(&"y".repeat(100)),603++ "long issue title kept"604++ );605++ assert!(606++ commits.contains(&"x".repeat(100)),607++ "long commit subject kept"608++ );609++ let _ = (evil_title, evil_subject);610++}611+-- 612+2.55.0613+