diff --git a/.forge/prs/6c1c74eb-c55e-49d1-b3ee-b9b435caede6/patch.diff b/.forge/prs/6c1c74eb-c55e-49d1-b3ee-b9b435caede6/patch.diffnew file mode 100644index 0000000..16fc8a3--- /dev/null+++ b/.forge/prs/6c1c74eb-c55e-49d1-b3ee-b9b435caede6/patch.diff@@ -0,0 +1,1595 @@1+From 4d92a6dad1c964972b7492285591f7d1d6b50666 Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 10:02:02 +01004+Subject: [PATCH] m6/cards2: file-table exempt, title-first cards (+sort5+ compat)6+7+---8+ .../merges/2026-09-10T08-57-21.752386694Z.md | 59 +++9+ .../merges/2026-09-10T08-58-33.103715749Z.md | 59 +++10+ .../merges/2026-09-10T09-01-18.492745439Z.md | 59 +++11+ .../merges/2026-09-10T09-01-43.282852796Z.md | 59 +++12+ .../patch.diff | 421 ++++++++++++++++++13+ .../pr.md | 12 +14+ .../patch.diff | 421 ++++++++++++++++++15+ .../pr.md | 12 +16+ src/build.rs | 57 ++-17+ src/commits.rs | 12 +-18+ src/forge.rs | 49 +-19+ src/releases.rs | 8 +-20+ static/style.css | 37 +-21+ tests/forge_readers.rs | 10 +-22+ tests/releases_api.rs | 8 +-23+ tests/truncate.rs | 18 +-24+ 16 files changed, 1241 insertions(+), 60 deletions(-)25+ create mode 100644 .forge/merges/2026-09-10T08-57-21.752386694Z.md26+ create mode 100644 .forge/merges/2026-09-10T08-58-33.103715749Z.md27+ create mode 100644 .forge/merges/2026-09-10T09-01-18.492745439Z.md28+ create mode 100644 .forge/merges/2026-09-10T09-01-43.282852796Z.md29+ create mode 100644 .forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff30+ create mode 100644 .forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/pr.md31+ create mode 100644 .forge/prs/1329e344-0a57-4508-aecc-aca2914005be/patch.diff32+ create mode 100644 .forge/prs/1329e344-0a57-4508-aecc-aca2914005be/pr.md33+34+diff --git a/.forge/merges/2026-09-10T08-57-21.752386694Z.md b/.forge/merges/2026-09-10T08-57-21.752386694Z.md35+new file mode 10064436+index 0000000..2a667b037+--- /dev/null38++++ b/.forge/merges/2026-09-10T08-57-21.752386694Z.md39+@@ -0,0 +1,59 @@40++---41++pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae42++applied: false43++verdict: fail44++resolved: []45++created: 2026-09-10T08:57:21.752386694Z46++---47++conflict: src/forge.rs48++patch: 67935671dbaf97887378ed0535994bf84da082a649++---50++file: src/forge.rs51++base master:src/forge.rs:52++<too large on base>53++head m6/cards2:src/forge.rs:54++<too large on head>55++patch:56++diff --git a/src/forge.rs b/src/forge.rs57++index 40aa052..d2827b6 10064458++--- a/src/forge.rs59+++++ b/src/forge.rs60++@@ -998,10 +998,10 @@ fn render_issues_index(61++ if data.issues.is_empty() {62++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");63++ } else {64++- 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");65+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");66++ for issue in &data.issues {67++ body.push_str(&format!(68++- "<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",69+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",70++ n = issue.number,71++ id = esc(&issue.id),72++ title = esc(&issue.title),73++@@ -1059,14 +1059,14 @@ fn render_pulls_index(74++ if prs.is_empty() {75++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");76++ } else {77++- 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");78+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");79++ for pr in prs {80++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));81++ let lanes_plain = format!("{}→{}", pr.head, pr.base);82++ let draft = if pr.draft { " draft" } else { "" };83++ let full_title = format!("{}{}", pr.title, draft);84++ body.push_str(&format!(85++- "<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",86+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",87++ n = pr.number,88++ id = esc(&pr.id),89++ title = esc(&pr.title),90++@@ -1823,11 +1823,19 @@ mod tests {91++ css.contains("content:attr(data-label)"),92++ "label via ::before"93++ );94++- assert!(css.contains(".table-scroll td::before"), "label rule");95++ assert!(96++- css.contains(".table-scroll td.title::before"),97+++ 98++...truncated99+diff --git a/.forge/merges/2026-09-10T08-58-33.103715749Z.md b/.forge/merges/2026-09-10T08-58-33.103715749Z.md100+new file mode 100644101+index 0000000..040e3cf102+--- /dev/null103++++ b/.forge/merges/2026-09-10T08-58-33.103715749Z.md104+@@ -0,0 +1,59 @@105++---106++pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae107++applied: false108++verdict: fail109++resolved: []110++created: 2026-09-10T08:58:33.103715749Z111++---112++conflict: src/forge.rs113++patch: 67935671dbaf97887378ed0535994bf84da082a6114++---115++file: src/forge.rs116++base master:src/forge.rs:117++<too large on base>118++head m6/cards2:src/forge.rs:119++<too large on head>120++patch:121++diff --git a/src/forge.rs b/src/forge.rs122++index 40aa052..d2827b6 100644123++--- a/src/forge.rs124+++++ b/src/forge.rs125++@@ -998,10 +998,10 @@ fn render_issues_index(126++ if data.issues.is_empty() {127++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");128++ } else {129++- 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");130+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");131++ for issue in &data.issues {132++ body.push_str(&format!(133++- "<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",134+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",135++ n = issue.number,136++ id = esc(&issue.id),137++ title = esc(&issue.title),138++@@ -1059,14 +1059,14 @@ fn render_pulls_index(139++ if prs.is_empty() {140++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");141++ } else {142++- 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");143+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");144++ for pr in prs {145++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));146++ let lanes_plain = format!("{}→{}", pr.head, pr.base);147++ let draft = if pr.draft { " draft" } else { "" };148++ let full_title = format!("{}{}", pr.title, draft);149++ body.push_str(&format!(150++- "<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",151+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",152++ n = pr.number,153++ id = esc(&pr.id),154++ title = esc(&pr.title),155++@@ -1823,11 +1823,19 @@ mod tests {156++ css.contains("content:attr(data-label)"),157++ "label via ::before"158++ );159++- assert!(css.contains(".table-scroll td::before"), "label rule");160++ assert!(161++- css.contains(".table-scroll td.title::before"),162+++ 163++...truncated164+diff --git a/.forge/merges/2026-09-10T09-01-18.492745439Z.md b/.forge/merges/2026-09-10T09-01-18.492745439Z.md165+new file mode 100644166+index 0000000..175b82c167+--- /dev/null168++++ b/.forge/merges/2026-09-10T09-01-18.492745439Z.md169+@@ -0,0 +1,59 @@170++---171++pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae172++applied: false173++verdict: fail174++resolved: []175++created: 2026-09-10T09:01:18.492745439Z176++---177++conflict: src/forge.rs178++patch: 67935671dbaf97887378ed0535994bf84da082a6179++---180++file: src/forge.rs181++base master:src/forge.rs:182++<too large on base>183++head m6/cards2:src/forge.rs:184++<too large on head>185++patch:186++diff --git a/src/forge.rs b/src/forge.rs187++index 40aa052..d2827b6 100644188++--- a/src/forge.rs189+++++ b/src/forge.rs190++@@ -998,10 +998,10 @@ fn render_issues_index(191++ if data.issues.is_empty() {192++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");193++ } else {194++- 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");195+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");196++ for issue in &data.issues {197++ body.push_str(&format!(198++- "<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",199+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",200++ n = issue.number,201++ id = esc(&issue.id),202++ title = esc(&issue.title),203++@@ -1059,14 +1059,14 @@ fn render_pulls_index(204++ if prs.is_empty() {205++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");206++ } else {207++- 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");208+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");209++ for pr in prs {210++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));211++ let lanes_plain = format!("{}→{}", pr.head, pr.base);212++ let draft = if pr.draft { " draft" } else { "" };213++ let full_title = format!("{}{}", pr.title, draft);214++ body.push_str(&format!(215++- "<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",216+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",217++ n = pr.number,218++ id = esc(&pr.id),219++ title = esc(&pr.title),220++@@ -1823,11 +1823,19 @@ mod tests {221++ css.contains("content:attr(data-label)"),222++ "label via ::before"223++ );224++- assert!(css.contains(".table-scroll td::before"), "label rule");225++ assert!(226++- css.contains(".table-scroll td.title::before"),227+++ 228++...truncated229+diff --git a/.forge/merges/2026-09-10T09-01-43.282852796Z.md b/.forge/merges/2026-09-10T09-01-43.282852796Z.md230+new file mode 100644231+index 0000000..c982aa0232+--- /dev/null233++++ b/.forge/merges/2026-09-10T09-01-43.282852796Z.md234+@@ -0,0 +1,59 @@235++---236++pr: 1329e344-0a57-4508-aecc-aca2914005be237++applied: false238++verdict: fail239++resolved: []240++created: 2026-09-10T09:01:43.282852796Z241++---242++conflict: src/forge.rs243++patch: 67935671dbaf97887378ed0535994bf84da082a6244++---245++file: src/forge.rs246++base master:src/forge.rs:247++<too large on base>248++head m6/cards2:src/forge.rs:249++<too large on head>250++patch:251++diff --git a/src/forge.rs b/src/forge.rs252++index 40aa052..d2827b6 100644253++--- a/src/forge.rs254+++++ b/src/forge.rs255++@@ -998,10 +998,10 @@ fn render_issues_index(256++ if data.issues.is_empty() {257++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");258++ } else {259++- 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");260+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");261++ for issue in &data.issues {262++ body.push_str(&format!(263++- "<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",264+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",265++ n = issue.number,266++ id = esc(&issue.id),267++ title = esc(&issue.title),268++@@ -1059,14 +1059,14 @@ fn render_pulls_index(269++ if prs.is_empty() {270++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");271++ } else {272++- 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");273+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");274++ for pr in prs {275++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));276++ let lanes_plain = format!("{}→{}", pr.head, pr.base);277++ let draft = if pr.draft { " draft" } else { "" };278++ let full_title = format!("{}{}", pr.title, draft);279++ body.push_str(&format!(280++- "<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",281+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",282++ n = pr.number,283++ id = esc(&pr.id),284++ title = esc(&pr.title),285++@@ -1823,11 +1823,19 @@ mod tests {286++ css.contains("content:attr(data-label)"),287++ "label via ::before"288++ );289++- assert!(css.contains(".table-scroll td::before"), "label rule");290++ assert!(291++- css.contains(".table-scroll td.title::before"),292+++ 293++...truncated294+diff --git a/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff295+new file mode 100644296+index 0000000..99535be297+--- /dev/null298++++ b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff299+@@ -0,0 +1,421 @@300++From 3af4cdd75682d5f1f264d233b4bc68d2f58ee6e0 Mon Sep 17 00:00:00 2001301++From: gl0w <gl0w@world>302++Date: Thu, 10 Sep 2026 09:57:21 +0100303++Subject: [PATCH] m6/cards2: file-table exempt, title-first cards304++305++---306++ src/build.rs | 57 ++++++++++++++++++++++++++----------------307++ src/commits.rs | 12 ++++++---308++ src/forge.rs | 47 +++++++++++++++++++++++++++++-----309++ src/releases.rs | 8 ++++--310++ static/style.css | 37 +++++++++++++++------------311++ tests/forge_readers.rs | 10 ++++----312++ tests/releases_api.rs | 8 +++---313++ tests/truncate.rs | 18 +++++++++++--314++ 8 files changed, 137 insertions(+), 60 deletions(-)315++316++diff --git a/src/build.rs b/src/build.rs317++index 6a12e44..f1c0795 100644318++--- a/src/build.rs319+++++ b/src/build.rs320++@@ -579,13 +579,13 @@ pub fn file_table_html(321++ history: &std::collections::BTreeMap<String, (String, String)>,322++ ) -> String {323++ let mut out = String::from(324++- "<div class=\"table-scroll\"><table class=\"file-table\">\n\325+++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">\n\326++ <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",327++ );328++ if let Some((href, label)) = parent {329++ out.push_str(&format!(330++- "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\331++- <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",332+++ "<tr class=\"parent\"><td class=\"trunc\" title=\"{}\"><a href=\"{}\">{}</a></td>\333+++ <td class=\"trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",334++ esc(label),335++ esc(href),336++ esc(label)337++@@ -601,8 +601,8 @@ pub fn file_table_html(338++ let msg_short = truncate_msg(&msg);339++ if e.is_dir {340++ out.push_str(&format!(341++- "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\342++- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",343+++ "<tr class=\"dir\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\344+++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",345++ esc(&e.name),346++ href_esc(&full),347++ esc(&e.name),348++@@ -613,8 +613,8 @@ pub fn file_table_html(349++ ));350++ } else {351++ out.push_str(&format!(352++- "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\353++- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",354+++ "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\355+++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",356++ esc(&e.name),357++ href_esc(&full),358++ esc(&e.name),359++@@ -1732,28 +1732,41 @@ mod tests {360++ assert!(sub.contains("title=\""), "title attrs kept");361++ }362++ 363++- /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.364+++ /// m6/cards2 (f0rge #46): file table stays a real table on all widths —365+++ /// no data-label card treatment, scroll wrapper kept, trunc + title kept.366++ #[test]367++- fn file_table_cells_have_data_labels() {368+++ fn file_table_stays_table_no_card_labels() {369++ let css = include_str!("../static/style.css");370++- assert!(css.contains("content:attr(data-label)"), "label rule");371++ assert!(css.contains("@media (max-width:767px)"), "narrow query");372+++ assert!(373+++ css.contains(".table-scroll:not(.file-scroll)"),374+++ "cards scoped off file-scroll"375+++ );376+++ assert!(377+++ css.contains(".file-scroll{overflow-x:auto}"),378+++ "file-scroll keeps h-scroll on narrow"379+++ );380++ let (_repo, out) = build_fixture();381++ for page in ["index.html", "files/sub.html"] {382++ let html = std::fs::read_to_string(out.join(page)).expect(page);383++- let tds = html.matches("<td").count();384++- assert!(tds > 0, "{page} has cells");385++- assert_eq!(386++- html.matches("data-label=").count(),387++- tds,388++- "{page}: every td labeled"389+++ assert!(390+++ html.contains(391+++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">"392+++ ),393+++ "{page}: scroll wrapper kept"394+++ );395+++ assert!(!html.contains("data-label="), "{page}: no card labels");396+++ assert!(397+++ html.contains("<td class=\"title trunc trunc-message\""),398+++ "{page}: message cell trunc kept"399+++ );400+++ assert!(html.contains("title=\""), "{page}: title attrs kept");401+++ assert!(402+++ html.contains("<th>Name</th>")403+++ && html.contains("<th>Last commit</th>")404+++ && html.contains("<th>Age</th>"),405+++ "{page}: headers kept"406++ );407++- for label in ["Name", "Last commit", "Age"] {408++- assert!(409++- html.contains(&format!("data-label=\"{label}\"")),410++- "{page}: label {label}"411++- );412++- }413++ }414++ }415++ 416++diff --git a/src/commits.rs b/src/commits.rs417++index 2324ee1..e3aa5de 100644418++--- a/src/commits.rs419+++++ b/src/commits.rs420++@@ -40,15 +40,15 @@ pub fn render_loaded(421++ if log.is_empty() {422++ body.push_str("<p class=\"muted\">No commits yet.</p>\n");423++ } else {424++- 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");425+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");426++ for c in log {427++ body.push_str(&format!(428++- "<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",429+++ "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",430+++ esc(&c.subject),431+++ esc(&c.subject),432++ esc(&c.sha),433++ esc(&c.sha),434++ esc(short_sha(&c.sha)),435++- esc(&c.subject),436++- esc(&c.subject),437++ esc(&c.author),438++ esc(&c.author),439++ esc(&c.date),440++@@ -423,5 +423,9 @@ mod tests {441++ "label {label}"442++ );443++ }444+++ // m6/cards2 (f0rge #47): card DOM order is subject-first.445+++ let subj = list.find("data-label=\"subject\"").expect("subject cell");446+++ let sha = list.find("data-label=\"sha\"").expect("sha cell");447+++ assert!(subj < sha, "card DOM: subject precedes sha");448++ }449++ }450++diff --git a/src/forge.rs b/src/forge.rs451++index 40aa052..d2827b6 100644452++--- a/src/forge.rs453+++++ b/src/forge.rs454++@@ -998,10 +998,10 @@ fn render_issues_index(455++ if data.issues.is_empty() {456++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");457++ } else {458++- 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");459+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");460++ for issue in &data.issues {461++ body.push_str(&format!(462++- "<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",463+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",464++ n = issue.number,465++ id = esc(&issue.id),466++ title = esc(&issue.title),467++@@ -1059,14 +1059,14 @@ fn render_pulls_index(468++ if prs.is_empty() {469++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");470++ } else {471++- 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");472+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");473++ for pr in prs {474++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));475++ let lanes_plain = format!("{}→{}", pr.head, pr.base);476++ let draft = if pr.draft { " draft" } else { "" };477++ let full_title = format!("{}{}", pr.title, draft);478++ body.push_str(&format!(479++- "<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",480+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",481++ n = pr.number,482++ id = esc(&pr.id),483++ title = esc(&pr.title),484++@@ -1823,11 +1823,19 @@ mod tests {485++ css.contains("content:attr(data-label)"),486++ "label via ::before"487++ );488++- assert!(css.contains(".table-scroll td::before"), "label rule");489++ assert!(490++- css.contains(".table-scroll td.title::before"),491+++ css.contains(".table-scroll:not(.file-scroll) td::before"),492+++ "label rule"493+++ );494+++ assert!(495+++ css.contains(".table-scroll:not(.file-scroll) td.title::before"),496++ "title leads card"497++ );498+++ // m6/cards2 (f0rge #46): file table exempt from the card reflow.499+++ assert!(500+++ css.contains(".file-scroll{overflow-x:auto}"),501+++ "file table keeps h-scroll on narrow"502+++ );503++ assert!(504++ css.contains("display:block") && css.contains("display:flex"),505++ "card layout"506++@@ -1894,8 +1902,35 @@ mod tests {507++ );508++ }509++ }510+++ // m6/cards2 (f0rge #47): card DOM order is title-first via template511+++ // cell order (no CSS order hacks) — desktop column headers match.512+++ for (page, first, second) in [513+++ (514+++ "issues/index.html",515+++ "data-label=\"title\"",516+++ "data-label=\"#\"",517+++ ),518+++ (519+++ "pulls/index.html",520+++ "data-label=\"title\"",521+++ "data-label=\"#\"",522+++ ),523+++ (524+++ "ci/index.html",525+++ "data-label=\"step\"",526+++ "data-label=\"exit\"",527+++ ),528+++ ] {529+++ let html = std::fs::read_to_string(out.join(page)).expect(page);530+++ let a = html.find(first).expect("first cell");531+++ let b = html.find(second).expect("second cell");532+++ assert!(a < b, "{page}: card DOM title precedes meta");533+++ }534++ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");535++ assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");536+++ let title_pos = tasks.find("Card task").expect("task title");537+++ let meta_pos = tasks.find("<code>cccccccc").expect("task meta");538+++ assert!(title_pos < meta_pos, "tasks: title precedes meta");539++ assert!(tasks.contains("todo (1)"), "tasks group kept");540++ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");541++ assert!(542++diff --git a/src/releases.rs b/src/releases.rs543++index 68c050b..cceb905 100644544++--- a/src/releases.rs545+++++ b/src/releases.rs546++@@ -395,10 +395,10 @@ pub fn render_loaded(547++ if releases.is_empty() {548++ body.push_str("<p class=\"muted\">No releases yet.</p>\n");549++ } else {550++- 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");551+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");552++ for rel in releases {553++ body.push_str(&format!(554++- "<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",555+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",556++ v = esc(&rel.version),557++ ve = esc(&rel.version),558++ t = esc(rel.display_title()),559++@@ -584,5 +584,9 @@ mod tests {560++ "label {label}"561++ );562++ }563+++ // m6/cards2 (f0rge #47): card DOM order is title-first.564+++ let title = index.find("data-label=\"title\"").expect("title cell");565+++ let ver = index.find("data-label=\"version\"").expect("version cell");566+++ assert!(title < ver, "card DOM: title precedes version");567++ }568++ }569++diff --git a/static/style.css b/static/style.css570++index d645df0..a305df3 100644571++--- a/static/style.css572+++++ b/static/style.css573++@@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem}574++ Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above.575++ Narrow: no scroll (overflow visible), thead clipped for SR only, each576++ tr becomes a bordered card, each td a labeled flex row via data-label.577++- Title cells lead bold without a repeated label; nothing is deleted. */578+++ Title cells lead bold without a repeated label; nothing is deleted.579+++ File table exempt (m6/cards2, f0rge #46): dense reference data stays a580+++ real table at every width — .file-scroll keeps horizontal scroll and581+++ table layout on narrow while every other list table becomes cards. */582++ @media (max-width:767px){583++- .table-scroll{overflow-x:visible}584++- .table-scroll table,.file-table table{width:100%;min-width:0}585++- .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}586++- .table-scroll tbody,.table-scroll tr{display:block;width:auto}587++- .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}588++- .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal}589++- .table-scroll td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}590++- .table-scroll td:last-child{border-bottom:none}591++- .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}592++- .table-scroll td.title::before{display:none}593++- .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0}594++- .table-scroll td.num,.table-scroll td.sha{width:auto}595++- .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}596++- .table-scroll tbody tr:hover td{background:none}597++- .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}598+++ .table-scroll:not(.file-scroll){overflow-x:visible}599+++ .table-scroll:not(.file-scroll) table{width:100%;min-width:0}600+++ .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}601+++ .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto}602+++ .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}603+++ .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal}604+++ .table-scroll:not(.file-scroll) td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}605+++ .table-scroll:not(.file-scroll) td:last-child{border-bottom:none}606+++ .table-scroll:not(.file-scroll) td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}607+++ .table-scroll:not(.file-scroll) td.title::before{display:none}608+++ .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0}609+++ .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto}610+++ .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}611+++ .table-scroll:not(.file-scroll) tbody tr:hover td{background:none}612+++ .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}613+++ .file-scroll{overflow-x:auto}614+++ .file-scroll table{width:max-content;min-width:100%}615++ }616++diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs617++index c03348b..b43bfcf 100644618++--- a/tests/forge_readers.rs619+++++ b/tests/forge_readers.rs620++@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {621++ 622++ // --- issues index: 2 rendered rows (broken one skipped) ---623++ let issues = read("issues/index.html");624++- let rows = issues.matches("<tr><td class=\"num").count();625+++ let rows = issues.matches("<tr><td class=\"title").count();626++ assert_eq!(rows, 2, "rendered issues match file-count minus broken");627++ assert!(!issues.contains("No title here"), "broken issue skipped");628++ // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.629++ assert!(630++ issues.contains(&format!(631++- "<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\">"632+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">Older issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"633++ )),634++ "issue #1 -> older uuid"635++ );636++ assert!(637++ issues.contains(&format!(638++- "<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\">"639+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">Newer issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td>"640++ )),641++ "issue #2 -> newer uuid"642++ );643++@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {644++ // (4): pulls — same shape + head/base/draft/ci + patch <pre>.645++ let pulls = read("pulls/index.html");646++ assert_eq!(647++- pulls.matches("<tr><td class=\"num").count(),648+++ pulls.matches("<tr><td class=\"title").count(),649++ 1,650++ "prs match file-count"651++ );652++ assert!(653++ pulls.contains(&format!(654++- "<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\">"655+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">Fix the thing</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"656++ )),657++ "pr #1 -> uuid"658++ );659++diff --git a/tests/releases_api.rs b/tests/releases_api.rs660++index 0c7e472..04e6cee 100644661++--- a/tests/releases_api.rs662+++++ b/tests/releases_api.rs663++@@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() {664++ 665++ // --- api counts == html counts (same struct pass) ---666++ let issues_html = read("issues/index.html")667++- .matches("<tr><td class=\"num")668+++ .matches("<tr><td class=\"title")669++ .count();670++ let pulls_html = read("pulls/index.html")671++- .matches("<tr><td class=\"num")672+++ .matches("<tr><td class=\"title")673+++ .count();674+++ let commits_html = read("commits.html")675+++ .matches("<tr><td class=\"title")676++ .count();677++- let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();678++ let releases_html = read("releases/index.html").matches("<tr><td").count();679++ let wiki_html = read("wiki/index.html")680++ .matches("<li><a href=\"/wiki/")681++diff --git a/tests/truncate.rs b/tests/truncate.rs682++index 12d9004..2f08e2d 100644683++--- a/tests/truncate.rs684+++++ b/tests/truncate.rs685++@@ -135,6 +135,12 @@ fn build_evil_fixture() -> (PathBuf, String, String) {686++ }687++ 688++ fn assert_every_td_truncated(html: &str, page: &str) {689+++ // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real690+++ // table — trunc + title= kept, no data-label card treatment.691+++ assert_every_td_truncated_opts(html, page, page != "index.html");692+++}693+++694+++fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) {695++ let parts: Vec<&str> = html.split("<td").skip(1).collect();696++ assert!(!parts.is_empty(), "{page} has cells");697++ for (i, p) in parts.iter().enumerate() {698++@@ -147,9 +153,17 @@ fn assert_every_td_truncated(html: &str, page: &str) {699++ head.contains("title="),700++ "{page} td #{i} carries title= (got {head:?})"701++ );702+++ if expect_labels {703+++ assert!(704+++ head.contains("data-label="),705+++ "{page} td #{i} keeps data-label (got {head:?})"706+++ );707+++ }708+++ }709+++ if !expect_labels {710++ assert!(711++- head.contains("data-label="),712++- "{page} td #{i} keeps data-label (got {head:?})"713+++ !html.contains("data-label="),714+++ "{page} has no card data-labels"715++ );716++ }717++ }718++-- 719++2.55.0720++721+diff --git a/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/pr.md b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/pr.md722+new file mode 100644723+index 0000000..b1fc288724+--- /dev/null725++++ b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/pr.md726+@@ -0,0 +1,12 @@727++---728++id: 0a15e49a-092a-4c82-a56c-fc03a36d0dae729++title: m6/cards2730++state: closed731++base: master732++head: m6/cards2733++draft: false734++issues: []735++ci: null736++created: 2026-09-10T08:57:21.717122363Z737++updated: 2026-09-10T09:01:43.234179940Z738++---739+diff --git a/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/patch.diff b/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/patch.diff740+new file mode 100644741+index 0000000..99535be742+--- /dev/null743++++ b/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/patch.diff744+@@ -0,0 +1,421 @@745++From 3af4cdd75682d5f1f264d233b4bc68d2f58ee6e0 Mon Sep 17 00:00:00 2001746++From: gl0w <gl0w@world>747++Date: Thu, 10 Sep 2026 09:57:21 +0100748++Subject: [PATCH] m6/cards2: file-table exempt, title-first cards749++750++---751++ src/build.rs | 57 ++++++++++++++++++++++++++----------------752++ src/commits.rs | 12 ++++++---753++ src/forge.rs | 47 +++++++++++++++++++++++++++++-----754++ src/releases.rs | 8 ++++--755++ static/style.css | 37 +++++++++++++++------------756++ tests/forge_readers.rs | 10 ++++----757++ tests/releases_api.rs | 8 +++---758++ tests/truncate.rs | 18 +++++++++++--759++ 8 files changed, 137 insertions(+), 60 deletions(-)760++761++diff --git a/src/build.rs b/src/build.rs762++index 6a12e44..f1c0795 100644763++--- a/src/build.rs764+++++ b/src/build.rs765++@@ -579,13 +579,13 @@ pub fn file_table_html(766++ history: &std::collections::BTreeMap<String, (String, String)>,767++ ) -> String {768++ let mut out = String::from(769++- "<div class=\"table-scroll\"><table class=\"file-table\">\n\770+++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">\n\771++ <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",772++ );773++ if let Some((href, label)) = parent {774++ out.push_str(&format!(775++- "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\776++- <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",777+++ "<tr class=\"parent\"><td class=\"trunc\" title=\"{}\"><a href=\"{}\">{}</a></td>\778+++ <td class=\"trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",779++ esc(label),780++ esc(href),781++ esc(label)782++@@ -601,8 +601,8 @@ pub fn file_table_html(783++ let msg_short = truncate_msg(&msg);784++ if e.is_dir {785++ out.push_str(&format!(786++- "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\787++- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",788+++ "<tr class=\"dir\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\789+++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",790++ esc(&e.name),791++ href_esc(&full),792++ esc(&e.name),793++@@ -613,8 +613,8 @@ pub fn file_table_html(794++ ));795++ } else {796++ out.push_str(&format!(797++- "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\798++- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",799+++ "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\800+++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",801++ esc(&e.name),802++ href_esc(&full),803++ esc(&e.name),804++@@ -1732,28 +1732,41 @@ mod tests {805++ assert!(sub.contains("title=\""), "title attrs kept");806++ }807++ 808++- /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.809+++ /// m6/cards2 (f0rge #46): file table stays a real table on all widths —810+++ /// no data-label card treatment, scroll wrapper kept, trunc + title kept.811++ #[test]812++- fn file_table_cells_have_data_labels() {813+++ fn file_table_stays_table_no_card_labels() {814++ let css = include_str!("../static/style.css");815++- assert!(css.contains("content:attr(data-label)"), "label rule");816++ assert!(css.contains("@media (max-width:767px)"), "narrow query");817+++ assert!(818+++ css.contains(".table-scroll:not(.file-scroll)"),819+++ "cards scoped off file-scroll"820+++ );821+++ assert!(822+++ css.contains(".file-scroll{overflow-x:auto}"),823+++ "file-scroll keeps h-scroll on narrow"824+++ );825++ let (_repo, out) = build_fixture();826++ for page in ["index.html", "files/sub.html"] {827++ let html = std::fs::read_to_string(out.join(page)).expect(page);828++- let tds = html.matches("<td").count();829++- assert!(tds > 0, "{page} has cells");830++- assert_eq!(831++- html.matches("data-label=").count(),832++- tds,833++- "{page}: every td labeled"834+++ assert!(835+++ html.contains(836+++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">"837+++ ),838+++ "{page}: scroll wrapper kept"839+++ );840+++ assert!(!html.contains("data-label="), "{page}: no card labels");841+++ assert!(842+++ html.contains("<td class=\"title trunc trunc-message\""),843+++ "{page}: message cell trunc kept"844+++ );845+++ assert!(html.contains("title=\""), "{page}: title attrs kept");846+++ assert!(847+++ html.contains("<th>Name</th>")848+++ && html.contains("<th>Last commit</th>")849+++ && html.contains("<th>Age</th>"),850+++ "{page}: headers kept"851++ );852++- for label in ["Name", "Last commit", "Age"] {853++- assert!(854++- html.contains(&format!("data-label=\"{label}\"")),855++- "{page}: label {label}"856++- );857++- }858++ }859++ }860++ 861++diff --git a/src/commits.rs b/src/commits.rs862++index 2324ee1..e3aa5de 100644863++--- a/src/commits.rs864+++++ b/src/commits.rs865++@@ -40,15 +40,15 @@ pub fn render_loaded(866++ if log.is_empty() {867++ body.push_str("<p class=\"muted\">No commits yet.</p>\n");868++ } else {869++- 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");870+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");871++ for c in log {872++ body.push_str(&format!(873++- "<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",874+++ "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",875+++ esc(&c.subject),876+++ esc(&c.subject),877++ esc(&c.sha),878++ esc(&c.sha),879++ esc(short_sha(&c.sha)),880++- esc(&c.subject),881++- esc(&c.subject),882++ esc(&c.author),883++ esc(&c.author),884++ esc(&c.date),885++@@ -423,5 +423,9 @@ mod tests {886++ "label {label}"887++ );888++ }889+++ // m6/cards2 (f0rge #47): card DOM order is subject-first.890+++ let subj = list.find("data-label=\"subject\"").expect("subject cell");891+++ let sha = list.find("data-label=\"sha\"").expect("sha cell");892+++ assert!(subj < sha, "card DOM: subject precedes sha");893++ }894++ }895++diff --git a/src/forge.rs b/src/forge.rs896++index 40aa052..d2827b6 100644897++--- a/src/forge.rs898+++++ b/src/forge.rs899++@@ -998,10 +998,10 @@ fn render_issues_index(900++ if data.issues.is_empty() {901++ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");902++ } else {903++- 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");904+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");905++ for issue in &data.issues {906++ body.push_str(&format!(907++- "<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",908+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",909++ n = issue.number,910++ id = esc(&issue.id),911++ title = esc(&issue.title),912++@@ -1059,14 +1059,14 @@ fn render_pulls_index(913++ if prs.is_empty() {914++ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");915++ } else {916++- 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");917+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");918++ for pr in prs {919++ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));920++ let lanes_plain = format!("{}→{}", pr.head, pr.base);921++ let draft = if pr.draft { " draft" } else { "" };922++ let full_title = format!("{}{}", pr.title, draft);923++ body.push_str(&format!(924++- "<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",925+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",926++ n = pr.number,927++ id = esc(&pr.id),928++ title = esc(&pr.title),929++@@ -1823,11 +1823,19 @@ mod tests {930++ css.contains("content:attr(data-label)"),931++ "label via ::before"932++ );933++- assert!(css.contains(".table-scroll td::before"), "label rule");934++ assert!(935++- css.contains(".table-scroll td.title::before"),936+++ css.contains(".table-scroll:not(.file-scroll) td::before"),937+++ "label rule"938+++ );939+++ assert!(940+++ css.contains(".table-scroll:not(.file-scroll) td.title::before"),941++ "title leads card"942++ );943+++ // m6/cards2 (f0rge #46): file table exempt from the card reflow.944+++ assert!(945+++ css.contains(".file-scroll{overflow-x:auto}"),946+++ "file table keeps h-scroll on narrow"947+++ );948++ assert!(949++ css.contains("display:block") && css.contains("display:flex"),950++ "card layout"951++@@ -1894,8 +1902,35 @@ mod tests {952++ );953++ }954++ }955+++ // m6/cards2 (f0rge #47): card DOM order is title-first via template956+++ // cell order (no CSS order hacks) — desktop column headers match.957+++ for (page, first, second) in [958+++ (959+++ "issues/index.html",960+++ "data-label=\"title\"",961+++ "data-label=\"#\"",962+++ ),963+++ (964+++ "pulls/index.html",965+++ "data-label=\"title\"",966+++ "data-label=\"#\"",967+++ ),968+++ (969+++ "ci/index.html",970+++ "data-label=\"step\"",971+++ "data-label=\"exit\"",972+++ ),973+++ ] {974+++ let html = std::fs::read_to_string(out.join(page)).expect(page);975+++ let a = html.find(first).expect("first cell");976+++ let b = html.find(second).expect("second cell");977+++ assert!(a < b, "{page}: card DOM title precedes meta");978+++ }979++ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");980++ assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");981+++ let title_pos = tasks.find("Card task").expect("task title");982+++ let meta_pos = tasks.find("<code>cccccccc").expect("task meta");983+++ assert!(title_pos < meta_pos, "tasks: title precedes meta");984++ assert!(tasks.contains("todo (1)"), "tasks group kept");985++ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");986++ assert!(987++diff --git a/src/releases.rs b/src/releases.rs988++index 68c050b..cceb905 100644989++--- a/src/releases.rs990+++++ b/src/releases.rs991++@@ -395,10 +395,10 @@ pub fn render_loaded(992++ if releases.is_empty() {993++ body.push_str("<p class=\"muted\">No releases yet.</p>\n");994++ } else {995++- 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");996+++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");997++ for rel in releases {998++ body.push_str(&format!(999++- "<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",1000+++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",1001++ v = esc(&rel.version),1002++ ve = esc(&rel.version),1003++ t = esc(rel.display_title()),1004++@@ -584,5 +584,9 @@ mod tests {1005++ "label {label}"1006++ );1007++ }1008+++ // m6/cards2 (f0rge #47): card DOM order is title-first.1009+++ let title = index.find("data-label=\"title\"").expect("title cell");1010+++ let ver = index.find("data-label=\"version\"").expect("version cell");1011+++ assert!(title < ver, "card DOM: title precedes version");1012++ }1013++ }1014++diff --git a/static/style.css b/static/style.css1015++index d645df0..a305df3 1006441016++--- a/static/style.css1017+++++ b/static/style.css1018++@@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem}1019++ Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above.1020++ Narrow: no scroll (overflow visible), thead clipped for SR only, each1021++ tr becomes a bordered card, each td a labeled flex row via data-label.1022++- Title cells lead bold without a repeated label; nothing is deleted. */1023+++ Title cells lead bold without a repeated label; nothing is deleted.1024+++ File table exempt (m6/cards2, f0rge #46): dense reference data stays a1025+++ real table at every width — .file-scroll keeps horizontal scroll and1026+++ table layout on narrow while every other list table becomes cards. */1027++ @media (max-width:767px){1028++- .table-scroll{overflow-x:visible}1029++- .table-scroll table,.file-table table{width:100%;min-width:0}1030++- .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}1031++- .table-scroll tbody,.table-scroll tr{display:block;width:auto}1032++- .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}1033++- .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal}1034++- .table-scroll td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}1035++- .table-scroll td:last-child{border-bottom:none}1036++- .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}1037++- .table-scroll td.title::before{display:none}1038++- .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0}1039++- .table-scroll td.num,.table-scroll td.sha{width:auto}1040++- .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}1041++- .table-scroll tbody tr:hover td{background:none}1042++- .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}1043+++ .table-scroll:not(.file-scroll){overflow-x:visible}1044+++ .table-scroll:not(.file-scroll) table{width:100%;min-width:0}1045+++ .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}1046+++ .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto}1047+++ .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}1048+++ .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal}1049+++ .table-scroll:not(.file-scroll) td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}1050+++ .table-scroll:not(.file-scroll) td:last-child{border-bottom:none}1051+++ .table-scroll:not(.file-scroll) td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}1052+++ .table-scroll:not(.file-scroll) td.title::before{display:none}1053+++ .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0}1054+++ .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto}1055+++ .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}1056+++ .table-scroll:not(.file-scroll) tbody tr:hover td{background:none}1057+++ .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}1058+++ .file-scroll{overflow-x:auto}1059+++ .file-scroll table{width:max-content;min-width:100%}1060++ }1061++diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs1062++index c03348b..b43bfcf 1006441063++--- a/tests/forge_readers.rs1064+++++ b/tests/forge_readers.rs1065++@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {1066++ 1067++ // --- issues index: 2 rendered rows (broken one skipped) ---1068++ let issues = read("issues/index.html");1069++- let rows = issues.matches("<tr><td class=\"num").count();1070+++ let rows = issues.matches("<tr><td class=\"title").count();1071++ assert_eq!(rows, 2, "rendered issues match file-count minus broken");1072++ assert!(!issues.contains("No title here"), "broken issue skipped");1073++ // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.1074++ assert!(1075++ issues.contains(&format!(1076++- "<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\">"1077+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">Older issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"1078++ )),1079++ "issue #1 -> older uuid"1080++ );1081++ assert!(1082++ issues.contains(&format!(1083++- "<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\">"1084+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">Newer issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td>"1085++ )),1086++ "issue #2 -> newer uuid"1087++ );1088++@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {1089++ // (4): pulls — same shape + head/base/draft/ci + patch <pre>.1090++ let pulls = read("pulls/index.html");1091++ assert_eq!(1092++- pulls.matches("<tr><td class=\"num").count(),1093+++ pulls.matches("<tr><td class=\"title").count(),1094++ 1,1095++ "prs match file-count"1096++ );1097++ assert!(1098++ pulls.contains(&format!(1099++- "<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\">"1100+++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">Fix the thing</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"1101++ )),1102++ "pr #1 -> uuid"1103++ );1104++diff --git a/tests/releases_api.rs b/tests/releases_api.rs1105++index 0c7e472..04e6cee 1006441106++--- a/tests/releases_api.rs1107+++++ b/tests/releases_api.rs1108++@@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() {1109++ 1110++ // --- api counts == html counts (same struct pass) ---1111++ let issues_html = read("issues/index.html")1112++- .matches("<tr><td class=\"num")1113+++ .matches("<tr><td class=\"title")1114++ .count();1115++ let pulls_html = read("pulls/index.html")1116++- .matches("<tr><td class=\"num")1117+++ .matches("<tr><td class=\"title")1118+++ .count();1119+++ let commits_html = read("commits.html")1120+++ .matches("<tr><td class=\"title")1121++ .count();1122++- let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();1123++ let releases_html = read("releases/index.html").matches("<tr><td").count();1124++ let wiki_html = read("wiki/index.html")1125++ .matches("<li><a href=\"/wiki/")1126++diff --git a/tests/truncate.rs b/tests/truncate.rs1127++index 12d9004..2f08e2d 1006441128++--- a/tests/truncate.rs1129+++++ b/tests/truncate.rs1130++@@ -135,6 +135,12 @@ fn build_evil_fixture() -> (PathBuf, String, String) {1131++ }1132++ 1133++ fn assert_every_td_truncated(html: &str, page: &str) {1134+++ // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real1135+++ // table — trunc + title= kept, no data-label card treatment.1136+++ assert_every_td_truncated_opts(html, page, page != "index.html");1137+++}1138+++1139+++fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) {1140++ let parts: Vec<&str> = html.split("<td").skip(1).collect();1141++ assert!(!parts.is_empty(), "{page} has cells");1142++ for (i, p) in parts.iter().enumerate() {1143++@@ -147,9 +153,17 @@ fn assert_every_td_truncated(html: &str, page: &str) {1144++ head.contains("title="),1145++ "{page} td #{i} carries title= (got {head:?})"1146++ );1147+++ if expect_labels {1148+++ assert!(1149+++ head.contains("data-label="),1150+++ "{page} td #{i} keeps data-label (got {head:?})"1151+++ );1152+++ }1153+++ }1154+++ if !expect_labels {1155++ assert!(1156++- head.contains("data-label="),1157++- "{page} td #{i} keeps data-label (got {head:?})"1158+++ !html.contains("data-label="),1159+++ "{page} has no card data-labels"1160++ );1161++ }1162++ }1163++-- 1164++2.55.01165++1166+diff --git a/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/pr.md b/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/pr.md1167+new file mode 1006441168+index 0000000..52896bf1169+--- /dev/null1170++++ b/.forge/prs/1329e344-0a57-4508-aecc-aca2914005be/pr.md1171+@@ -0,0 +1,12 @@1172++---1173++id: 1329e344-0a57-4508-aecc-aca2914005be1174++title: m6/cards21175++state: open1176++base: master1177++head: m6/cards21178++draft: false1179++issues: []1180++ci: null1181++created: 2026-09-10T09:01:43.245267981Z1182++updated: null1183++---1184+diff --git a/src/build.rs b/src/build.rs1185+index 6a12e44..f1c0795 1006441186+--- a/src/build.rs1187++++ b/src/build.rs1188+@@ -579,13 +579,13 @@ pub fn file_table_html(1189+ history: &std::collections::BTreeMap<String, (String, String)>,1190+ ) -> String {1191+ let mut out = String::from(1192+- "<div class=\"table-scroll\"><table class=\"file-table\">\n\1193++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">\n\1194+ <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",1195+ );1196+ if let Some((href, label)) = parent {1197+ out.push_str(&format!(1198+- "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\1199+- <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",1200++ "<tr class=\"parent\"><td class=\"trunc\" title=\"{}\"><a href=\"{}\">{}</a></td>\1201++ <td class=\"trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",1202+ esc(label),1203+ esc(href),1204+ esc(label)1205+@@ -601,8 +601,8 @@ pub fn file_table_html(1206+ let msg_short = truncate_msg(&msg);1207+ if e.is_dir {1208+ out.push_str(&format!(1209+- "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\1210+- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",1211++ "<tr class=\"dir\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\1212++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1213+ esc(&e.name),1214+ href_esc(&full),1215+ esc(&e.name),1216+@@ -613,8 +613,8 @@ pub fn file_table_html(1217+ ));1218+ } else {1219+ out.push_str(&format!(1220+- "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\1221+- <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",1222++ "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\1223++ <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1224+ esc(&e.name),1225+ href_esc(&full),1226+ esc(&e.name),1227+@@ -1732,28 +1732,41 @@ mod tests {1228+ assert!(sub.contains("title=\""), "title attrs kept");1229+ }1230+ 1231+- /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.1232++ /// m6/cards2 (f0rge #46): file table stays a real table on all widths —1233++ /// no data-label card treatment, scroll wrapper kept, trunc + title kept.1234+ #[test]1235+- fn file_table_cells_have_data_labels() {1236++ fn file_table_stays_table_no_card_labels() {1237+ let css = include_str!("../static/style.css");1238+- assert!(css.contains("content:attr(data-label)"), "label rule");1239+ assert!(css.contains("@media (max-width:767px)"), "narrow query");1240++ assert!(1241++ css.contains(".table-scroll:not(.file-scroll)"),1242++ "cards scoped off file-scroll"1243++ );1244++ assert!(1245++ css.contains(".file-scroll{overflow-x:auto}"),1246++ "file-scroll keeps h-scroll on narrow"1247++ );1248+ let (_repo, out) = build_fixture();1249+ for page in ["index.html", "files/sub.html"] {1250+ let html = std::fs::read_to_string(out.join(page)).expect(page);1251+- let tds = html.matches("<td").count();1252+- assert!(tds > 0, "{page} has cells");1253+- assert_eq!(1254+- html.matches("data-label=").count(),1255+- tds,1256+- "{page}: every td labeled"1257++ assert!(1258++ html.contains(1259++ "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">"1260++ ),1261++ "{page}: scroll wrapper kept"1262++ );1263++ assert!(!html.contains("data-label="), "{page}: no card labels");1264++ assert!(1265++ html.contains("<td class=\"title trunc trunc-message\""),1266++ "{page}: message cell trunc kept"1267++ );1268++ assert!(html.contains("title=\""), "{page}: title attrs kept");1269++ assert!(1270++ html.contains("<th>Name</th>")1271++ && html.contains("<th>Last commit</th>")1272++ && html.contains("<th>Age</th>"),1273++ "{page}: headers kept"1274+ );1275+- for label in ["Name", "Last commit", "Age"] {1276+- assert!(1277+- html.contains(&format!("data-label=\"{label}\"")),1278+- "{page}: label {label}"1279+- );1280+- }1281+ }1282+ }1283+ 1284+diff --git a/src/commits.rs b/src/commits.rs1285+index 2324ee1..e3aa5de 1006441286+--- a/src/commits.rs1287++++ b/src/commits.rs1288+@@ -40,15 +40,15 @@ pub fn render_loaded(1289+ if log.is_empty() {1290+ body.push_str("<p class=\"muted\">No commits yet.</p>\n");1291+ } else {1292+- 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");1293++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");1294+ for c in log {1295+ body.push_str(&format!(1296+- "<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",1297++ "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",1298++ esc(&c.subject),1299++ esc(&c.subject),1300+ esc(&c.sha),1301+ esc(&c.sha),1302+ esc(short_sha(&c.sha)),1303+- esc(&c.subject),1304+- esc(&c.subject),1305+ esc(&c.author),1306+ esc(&c.author),1307+ esc(&c.date),1308+@@ -423,5 +423,9 @@ mod tests {1309+ "label {label}"1310+ );1311+ }1312++ // m6/cards2 (f0rge #47): card DOM order is subject-first.1313++ let subj = list.find("data-label=\"subject\"").expect("subject cell");1314++ let sha = list.find("data-label=\"sha\"").expect("sha cell");1315++ assert!(subj < sha, "card DOM: subject precedes sha");1316+ }1317+ }1318+diff --git a/src/forge.rs b/src/forge.rs1319+index f92aabd..01107e8 1006441320+--- a/src/forge.rs1321++++ b/src/forge.rs1322+@@ -1052,12 +1052,13 @@ fn render_issues_index(1323+ if data.issues.is_empty() {1324+ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");1325+ } else {1326+- 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");1327++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");1328+ // m6/sort (#48 + #49): open first (newest-first within), then1329+ // non-open (newest-first within). Numbers stay creation-rank.1330++ // m6/cards2 (#46 + #47): title-first column order.1331+ for issue in data.issues_in_display_order() {1332+ body.push_str(&format!(1333+- "<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",1334++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",1335+ n = issue.number,1336+ id = esc(&issue.id),1337+ title = esc(&issue.title),1338+@@ -1114,17 +1115,18 @@ fn render_pulls_index(1339+ if data.prs.is_empty() {1340+ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");1341+ } else {1342+- 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");1343++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");1344+ // m6/sort (#48 + #49): open (incl drafts) first (newest-first1345+ // within), then closed/merged (newest-first within).1346+ // Numbers stay creation-rank.1347++ // m6/cards2 (#46 + #47): title-first column order.1348+ for pr in data.prs_in_display_order() {1349+ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1350+ let lanes_plain = format!("{}→{}", pr.head, pr.base);1351+ let draft = if pr.draft { " draft" } else { "" };1352+ let full_title = format!("{}{}", pr.title, draft);1353+ body.push_str(&format!(1354+- "<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",1355++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</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",1356+ n = pr.number,1357+ id = esc(&pr.id),1358+ title = esc(&pr.title),1359+@@ -2073,11 +2075,19 @@ mod tests {1360+ css.contains("content:attr(data-label)"),1361+ "label via ::before"1362+ );1363+- assert!(css.contains(".table-scroll td::before"), "label rule");1364+ assert!(1365+- css.contains(".table-scroll td.title::before"),1366++ css.contains(".table-scroll:not(.file-scroll) td::before"),1367++ "label rule"1368++ );1369++ assert!(1370++ css.contains(".table-scroll:not(.file-scroll) td.title::before"),1371+ "title leads card"1372+ );1373++ // m6/cards2 (f0rge #46): file table exempt from the card reflow.1374++ assert!(1375++ css.contains(".file-scroll{overflow-x:auto}"),1376++ "file table keeps h-scroll on narrow"1377++ );1378+ assert!(1379+ css.contains("display:block") && css.contains("display:flex"),1380+ "card layout"1381+@@ -2144,8 +2154,35 @@ mod tests {1382+ );1383+ }1384+ }1385++ // m6/cards2 (f0rge #47): card DOM order is title-first via template1386++ // cell order (no CSS order hacks) — desktop column headers match.1387++ for (page, first, second) in [1388++ (1389++ "issues/index.html",1390++ "data-label=\"title\"",1391++ "data-label=\"#\"",1392++ ),1393++ (1394++ "pulls/index.html",1395++ "data-label=\"title\"",1396++ "data-label=\"#\"",1397++ ),1398++ (1399++ "ci/index.html",1400++ "data-label=\"step\"",1401++ "data-label=\"exit\"",1402++ ),1403++ ] {1404++ let html = std::fs::read_to_string(out.join(page)).expect(page);1405++ let a = html.find(first).expect("first cell");1406++ let b = html.find(second).expect("second cell");1407++ assert!(a < b, "{page}: card DOM title precedes meta");1408++ }1409+ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");1410+ assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");1411++ let title_pos = tasks.find("Card task").expect("task title");1412++ let meta_pos = tasks.find("<code>cccccccc").expect("task meta");1413++ assert!(title_pos < meta_pos, "tasks: title precedes meta");1414+ assert!(tasks.contains("todo (1)"), "tasks group kept");1415+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1416+ assert!(1417+diff --git a/src/releases.rs b/src/releases.rs1418+index 68c050b..cceb905 1006441419+--- a/src/releases.rs1420++++ b/src/releases.rs1421+@@ -395,10 +395,10 @@ pub fn render_loaded(1422+ if releases.is_empty() {1423+ body.push_str("<p class=\"muted\">No releases yet.</p>\n");1424+ } else {1425+- 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");1426++ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");1427+ for rel in releases {1428+ body.push_str(&format!(1429+- "<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",1430++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",1431+ v = esc(&rel.version),1432+ ve = esc(&rel.version),1433+ t = esc(rel.display_title()),1434+@@ -584,5 +584,9 @@ mod tests {1435+ "label {label}"1436+ );1437+ }1438++ // m6/cards2 (f0rge #47): card DOM order is title-first.1439++ let title = index.find("data-label=\"title\"").expect("title cell");1440++ let ver = index.find("data-label=\"version\"").expect("version cell");1441++ assert!(title < ver, "card DOM: title precedes version");1442+ }1443+ }1444+diff --git a/static/style.css b/static/style.css1445+index d645df0..a305df3 1006441446+--- a/static/style.css1447++++ b/static/style.css1448+@@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem}1449+ Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above.1450+ Narrow: no scroll (overflow visible), thead clipped for SR only, each1451+ tr becomes a bordered card, each td a labeled flex row via data-label.1452+- Title cells lead bold without a repeated label; nothing is deleted. */1453++ Title cells lead bold without a repeated label; nothing is deleted.1454++ File table exempt (m6/cards2, f0rge #46): dense reference data stays a1455++ real table at every width — .file-scroll keeps horizontal scroll and1456++ table layout on narrow while every other list table becomes cards. */1457+ @media (max-width:767px){1458+- .table-scroll{overflow-x:visible}1459+- .table-scroll table,.file-table table{width:100%;min-width:0}1460+- .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}1461+- .table-scroll tbody,.table-scroll tr{display:block;width:auto}1462+- .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}1463+- .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal}1464+- .table-scroll td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}1465+- .table-scroll td:last-child{border-bottom:none}1466+- .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}1467+- .table-scroll td.title::before{display:none}1468+- .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0}1469+- .table-scroll td.num,.table-scroll td.sha{width:auto}1470+- .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}1471+- .table-scroll tbody tr:hover td{background:none}1472+- .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}1473++ .table-scroll:not(.file-scroll){overflow-x:visible}1474++ .table-scroll:not(.file-scroll) table{width:100%;min-width:0}1475++ .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}1476++ .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto}1477++ .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}1478++ .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal}1479++ .table-scroll:not(.file-scroll) td{display:flex;gap:.5rem;align-items:baseline;padding:.4rem .75rem;border:none;border-bottom:1px solid var(--border);min-width:0;overflow-wrap:anywhere}1480++ .table-scroll:not(.file-scroll) td:last-child{border-bottom:none}1481++ .table-scroll:not(.file-scroll) td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}1482++ .table-scroll:not(.file-scroll) td.title::before{display:none}1483++ .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0}1484++ .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto}1485++ .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}1486++ .table-scroll:not(.file-scroll) tbody tr:hover td{background:none}1487++ .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}1488++ .file-scroll{overflow-x:auto}1489++ .file-scroll table{width:max-content;min-width:100%}1490+ }1491+diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs1492+index c03348b..b43bfcf 1006441493+--- a/tests/forge_readers.rs1494++++ b/tests/forge_readers.rs1495+@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {1496+ 1497+ // --- issues index: 2 rendered rows (broken one skipped) ---1498+ let issues = read("issues/index.html");1499+- let rows = issues.matches("<tr><td class=\"num").count();1500++ let rows = issues.matches("<tr><td class=\"title").count();1501+ assert_eq!(rows, 2, "rendered issues match file-count minus broken");1502+ assert!(!issues.contains("No title here"), "broken issue skipped");1503+ // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.1504+ assert!(1505+ issues.contains(&format!(1506+- "<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\">"1507++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">Older issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"1508+ )),1509+ "issue #1 -> older uuid"1510+ );1511+ assert!(1512+ issues.contains(&format!(1513+- "<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\">"1514++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">Newer issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td>"1515+ )),1516+ "issue #2 -> newer uuid"1517+ );1518+@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {1519+ // (4): pulls — same shape + head/base/draft/ci + patch <pre>.1520+ let pulls = read("pulls/index.html");1521+ assert_eq!(1522+- pulls.matches("<tr><td class=\"num").count(),1523++ pulls.matches("<tr><td class=\"title").count(),1524+ 1,1525+ "prs match file-count"1526+ );1527+ assert!(1528+ pulls.contains(&format!(1529+- "<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\">"1530++ "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">Fix the thing</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"1531+ )),1532+ "pr #1 -> uuid"1533+ );1534+diff --git a/tests/releases_api.rs b/tests/releases_api.rs1535+index 0c7e472..04e6cee 1006441536+--- a/tests/releases_api.rs1537++++ b/tests/releases_api.rs1538+@@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() {1539+ 1540+ // --- api counts == html counts (same struct pass) ---1541+ let issues_html = read("issues/index.html")1542+- .matches("<tr><td class=\"num")1543++ .matches("<tr><td class=\"title")1544+ .count();1545+ let pulls_html = read("pulls/index.html")1546+- .matches("<tr><td class=\"num")1547++ .matches("<tr><td class=\"title")1548++ .count();1549++ let commits_html = read("commits.html")1550++ .matches("<tr><td class=\"title")1551+ .count();1552+- let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();1553+ let releases_html = read("releases/index.html").matches("<tr><td").count();1554+ let wiki_html = read("wiki/index.html")1555+ .matches("<li><a href=\"/wiki/")1556+diff --git a/tests/truncate.rs b/tests/truncate.rs1557+index 12d9004..2f08e2d 1006441558+--- a/tests/truncate.rs1559++++ b/tests/truncate.rs1560+@@ -135,6 +135,12 @@ fn build_evil_fixture() -> (PathBuf, String, String) {1561+ }1562+ 1563+ fn assert_every_td_truncated(html: &str, page: &str) {1564++ // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real1565++ // table — trunc + title= kept, no data-label card treatment.1566++ assert_every_td_truncated_opts(html, page, page != "index.html");1567++}1568++1569++fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) {1570+ let parts: Vec<&str> = html.split("<td").skip(1).collect();1571+ assert!(!parts.is_empty(), "{page} has cells");1572+ for (i, p) in parts.iter().enumerate() {1573+@@ -147,9 +153,17 @@ fn assert_every_td_truncated(html: &str, page: &str) {1574+ head.contains("title="),1575+ "{page} td #{i} carries title= (got {head:?})"1576+ );1577++ if expect_labels {1578++ assert!(1579++ head.contains("data-label="),1580++ "{page} td #{i} keeps data-label (got {head:?})"1581++ );1582++ }1583++ }1584++ if !expect_labels {1585+ assert!(1586+- head.contains("data-label="),1587+- "{page} td #{i} keeps data-label (got {head:?})"1588++ !html.contains("data-label="),1589++ "{page} has no card data-labels"1590+ );1591+ }1592+ }1593+-- 1594+2.55.01595+