1 From ff259a6d4be192939058841d3d1d829b65fac1bc 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