land #42 truncate + f0rge state

7580a55gl0w

all commits

Message

land #42 truncate + f0rge state

Diff

.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/comments/1ce2b50d-f813-4832-beda-bb0c1a133a04.md+6 −0
diff --git a/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/comments/1ce2b50d-f813-4832-beda-bb0c1a133a04.md b/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/comments/1ce2b50d-f813-4832-beda-bb0c1a133a04.mdnew file mode 100644index 0000000..d19441a--- /dev/null+++ b/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/comments/1ce2b50d-f813-4832-beda-bb0c1a133a04.md@@ -0,0 +1,6 @@1+---2+id: 1ce2b50d-f813-4832-beda-bb0c1a133a043+author: null4+created: 2026-09-10T08:36:27.056936034Z5+---6+Merged PR a5e125a8 (m6/truncate): ellipsis + title attrs on every table cell (95 truncs on commits page alone), desktop fits container. CI pass. Rubric verified.
.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/issue.md+2 −2
diff --git a/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/issue.md b/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/issue.mdindex c900c9a..04e9773 100644--- a/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/issue.md+++ b/.forge/issues/cb3059a3-5dc8-48cc-84de-fdda3046a63d/issue.md@@ -1,13 +1,13 @@11 ---22 id: cb3059a3-5dc8-48cc-84de-fdda3046a63d33 title: Truncate all table fields (desktop overflow)4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:26:26.643766746Z10-updated: null10+updated: 2026-09-10T08:36:27.060152093Z1111 ---1212 Context: Owner QA — tables overflow even on desktop (commits subject/author worst). Every field needs truncation.1313 Tasks:
.forge/merges/2026-09-10T08-35-20.291609622Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T08-35-20.291609622Z.md b/.forge/merges/2026-09-10T08-35-20.291609622Z.mdnew file mode 100644index 0000000..c5999dd--- /dev/null+++ b/.forge/merges/2026-09-10T08-35-20.291609622Z.md@@ -0,0 +1,10 @@1+---2+pr: a5e125a8-e7e9-44b1-9d32-47a8a59e51c23+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T08:35:20.291609622Z7+---8+applied: efeb5945760aea0980b78b36617ef7beddb9bd649+patch: 8d34d3d0c417431d1c88170784b79e4563cbf13a10+pass.
.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/patch.diff+613 −0
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("&lt;script&gt;"), "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+
.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/pr.md+12 −0
diff --git a/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/pr.md b/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/pr.mdnew file mode 100644index 0000000..5d0ca08--- /dev/null+++ b/.forge/prs/a5e125a8-e7e9-44b1-9d32-47a8a59e51c2/pr.md@@ -0,0 +1,12 @@1+---2+id: a5e125a8-e7e9-44b1-9d32-47a8a59e51c23+title: m6/truncate4+state: merged5+base: master6+head: m6/truncate7+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T08:35:20.256529437Z11+updated: 2026-09-10T08:35:55.365032728Z12+---