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