From ff259a6d4be192939058841d3d1d829b65fac1bc Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 10:02:02 +0100 Subject: [PATCH] m6/cards2: file-table exempt, title-first cards (+sort compat) --- .../merges/2026-09-10T08-57-21.752386694Z.md | 59 +++ .../merges/2026-09-10T08-58-33.103715749Z.md | 59 +++ .../merges/2026-09-10T09-01-18.492745439Z.md | 59 +++ .../merges/2026-09-10T09-01-43.282852796Z.md | 59 +++ .../patch.diff | 421 ++++++++++++++++++ .../pr.md | 12 + .../patch.diff | 421 ++++++++++++++++++ .../pr.md | 12 + src/build.rs | 57 ++- src/commits.rs | 12 +- src/forge.rs | 49 +- src/releases.rs | 8 +- static/style.css | 37 +- tests/forge_readers.rs | 10 +- tests/releases_api.rs | 8 +- tests/truncate.rs | 18 +- 16 files changed, 1241 insertions(+), 60 deletions(-) create mode 100644 .forge/merges/2026-09-10T08-57-21.752386694Z.md create mode 100644 .forge/merges/2026-09-10T08-58-33.103715749Z.md create mode 100644 .forge/merges/2026-09-10T09-01-18.492745439Z.md create mode 100644 .forge/merges/2026-09-10T09-01-43.282852796Z.md create mode 100644 .forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff create mode 100644 .forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/pr.md create mode 100644 .forge/prs/1329e344-0a57-4508-aecc-aca2914005be/patch.diff create mode 100644 .forge/prs/1329e344-0a57-4508-aecc-aca2914005be/pr.md diff --git a/.forge/merges/2026-09-10T08-57-21.752386694Z.md b/.forge/merges/2026-09-10T08-57-21.752386694Z.md new file mode 100644 index 0000000..2a667b0 --- /dev/null +++ b/.forge/merges/2026-09-10T08-57-21.752386694Z.md @@ -0,0 +1,59 @@ +--- +pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae +applied: false +verdict: fail +resolved: [] +created: 2026-09-10T08:57:21.752386694Z +--- +conflict: src/forge.rs +patch: 67935671dbaf97887378ed0535994bf84da082a6 +--- +file: src/forge.rs +base master:src/forge.rs: + +head m6/cards2:src/forge.rs: + +patch: +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ +...truncated diff --git a/.forge/merges/2026-09-10T08-58-33.103715749Z.md b/.forge/merges/2026-09-10T08-58-33.103715749Z.md new file mode 100644 index 0000000..040e3cf --- /dev/null +++ b/.forge/merges/2026-09-10T08-58-33.103715749Z.md @@ -0,0 +1,59 @@ +--- +pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae +applied: false +verdict: fail +resolved: [] +created: 2026-09-10T08:58:33.103715749Z +--- +conflict: src/forge.rs +patch: 67935671dbaf97887378ed0535994bf84da082a6 +--- +file: src/forge.rs +base master:src/forge.rs: + +head m6/cards2:src/forge.rs: + +patch: +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ +...truncated diff --git a/.forge/merges/2026-09-10T09-01-18.492745439Z.md b/.forge/merges/2026-09-10T09-01-18.492745439Z.md new file mode 100644 index 0000000..175b82c --- /dev/null +++ b/.forge/merges/2026-09-10T09-01-18.492745439Z.md @@ -0,0 +1,59 @@ +--- +pr: 0a15e49a-092a-4c82-a56c-fc03a36d0dae +applied: false +verdict: fail +resolved: [] +created: 2026-09-10T09:01:18.492745439Z +--- +conflict: src/forge.rs +patch: 67935671dbaf97887378ed0535994bf84da082a6 +--- +file: src/forge.rs +base master:src/forge.rs: + +head m6/cards2:src/forge.rs: + +patch: +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ +...truncated diff --git a/.forge/merges/2026-09-10T09-01-43.282852796Z.md b/.forge/merges/2026-09-10T09-01-43.282852796Z.md new file mode 100644 index 0000000..c982aa0 --- /dev/null +++ b/.forge/merges/2026-09-10T09-01-43.282852796Z.md @@ -0,0 +1,59 @@ +--- +pr: 1329e344-0a57-4508-aecc-aca2914005be +applied: false +verdict: fail +resolved: [] +created: 2026-09-10T09:01:43.282852796Z +--- +conflict: src/forge.rs +patch: 67935671dbaf97887378ed0535994bf84da082a6 +--- +file: src/forge.rs +base master:src/forge.rs: + +head m6/cards2:src/forge.rs: + +patch: +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ +...truncated diff --git a/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff new file mode 100644 index 0000000..99535be --- /dev/null +++ b/.forge/prs/0a15e49a-092a-4c82-a56c-fc03a36d0dae/patch.diff @@ -0,0 +1,421 @@ +From 3af4cdd75682d5f1f264d233b4bc68d2f58ee6e0 Mon Sep 17 00:00:00 2001 +From: gl0w +Date: Thu, 10 Sep 2026 09:57:21 +0100 +Subject: [PATCH] m6/cards2: file-table exempt, title-first cards + +--- + src/build.rs | 57 ++++++++++++++++++++++++++---------------- + src/commits.rs | 12 ++++++--- + src/forge.rs | 47 +++++++++++++++++++++++++++++----- + src/releases.rs | 8 ++++-- + static/style.css | 37 +++++++++++++++------------ + tests/forge_readers.rs | 10 ++++---- + tests/releases_api.rs | 8 +++--- + tests/truncate.rs | 18 +++++++++++-- + 8 files changed, 137 insertions(+), 60 deletions(-) + +diff --git a/src/build.rs b/src/build.rs +index 6a12e44..f1c0795 100644 +--- a/src/build.rs ++++ b/src/build.rs +@@ -579,13 +579,13 @@ pub fn file_table_html( + history: &std::collections::BTreeMap, + ) -> String { + let mut out = String::from( +- "
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\ ++ "
\n\ + \n\n", + ); + if let Some((href, label)) = parent { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(label), + esc(href), + esc(label) +@@ -601,8 +601,8 @@ pub fn file_table_html( + let msg_short = truncate_msg(&msg); + if e.is_dir { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(&e.name), + href_esc(&full), + esc(&e.name), +@@ -613,8 +613,8 @@ pub fn file_table_html( + )); + } else { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(&e.name), + href_esc(&full), + esc(&e.name), +@@ -1732,28 +1732,41 @@ mod tests { + assert!(sub.contains("title=\""), "title attrs kept"); + } + +- /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards. ++ /// m6/cards2 (f0rge #46): file table stays a real table on all widths — ++ /// no data-label card treatment, scroll wrapper kept, trunc + title kept. + #[test] +- fn file_table_cells_have_data_labels() { ++ fn file_table_stays_table_no_card_labels() { + let css = include_str!("../static/style.css"); +- assert!(css.contains("content:attr(data-label)"), "label rule"); + assert!(css.contains("@media (max-width:767px)"), "narrow query"); ++ assert!( ++ css.contains(".table-scroll:not(.file-scroll)"), ++ "cards scoped off file-scroll" ++ ); ++ assert!( ++ css.contains(".file-scroll{overflow-x:auto}"), ++ "file-scroll keeps h-scroll on narrow" ++ ); + let (_repo, out) = build_fixture(); + for page in ["index.html", "files/sub.html"] { + let html = std::fs::read_to_string(out.join(page)).expect(page); +- let tds = html.matches(" 0, "{page} has cells"); +- assert_eq!( +- html.matches("data-label=").count(), +- tds, +- "{page}: every td labeled" ++ assert!( ++ html.contains( ++ "
NameLast commitAge
{}
{}
{}/{}{}
{}/{}{}
{}{}{}
{}{}{}
" ++ ), ++ "{page}: scroll wrapper kept" ++ ); ++ assert!(!html.contains("data-label="), "{page}: no card labels"); ++ assert!( ++ html.contains("") ++ && html.contains(""), ++ "{page}: headers kept" + ); +- for label in ["Name", "Last commit", "Age"] { +- assert!( +- html.contains(&format!("data-label=\"{label}\"")), +- "{page}: label {label}" +- ); +- } + } + } + +diff --git a/src/commits.rs b/src/commits.rs +index 2324ee1..e3aa5de 100644 +--- a/src/commits.rs ++++ b/src/commits.rs +@@ -40,15 +40,15 @@ pub fn render_loaded( + if log.is_empty() { + body.push_str("

No commits yet.

\n"); + } else { +- body.push_str("
Name") ++ && html.contains("Last commitAge
\n\n\n"); ++ body.push_str("
shasubjectauthordate
\n\n\n"); + for c in log { + body.push_str(&format!( +- "\n", ++ "\n", ++ esc(&c.subject), ++ esc(&c.subject), + esc(&c.sha), + esc(&c.sha), + esc(short_sha(&c.sha)), +- esc(&c.subject), +- esc(&c.subject), + esc(&c.author), + esc(&c.author), + esc(&c.date), +@@ -423,5 +423,9 @@ mod tests { + "label {label}" + ); + } ++ // m6/cards2 (f0rge #47): card DOM order is subject-first. ++ let subj = list.find("data-label=\"subject\"").expect("subject cell"); ++ let sha = list.find("data-label=\"sha\"").expect("sha cell"); ++ assert!(subj < sha, "card DOM: subject precedes sha"); + } + } +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
subjectshaauthordate
{}{}{}{}
{}{}{}{}
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ css.contains(".table-scroll:not(.file-scroll) td::before"), ++ "label rule" ++ ); ++ assert!( ++ css.contains(".table-scroll:not(.file-scroll) td.title::before"), + "title leads card" + ); ++ // m6/cards2 (f0rge #46): file table exempt from the card reflow. ++ assert!( ++ css.contains(".file-scroll{overflow-x:auto}"), ++ "file table keeps h-scroll on narrow" ++ ); + assert!( + css.contains("display:block") && css.contains("display:flex"), + "card layout" +@@ -1894,8 +1902,35 @@ mod tests { + ); + } + } ++ // m6/cards2 (f0rge #47): card DOM order is title-first via template ++ // cell order (no CSS order hacks) — desktop column headers match. ++ for (page, first, second) in [ ++ ( ++ "issues/index.html", ++ "data-label=\"title\"", ++ "data-label=\"#\"", ++ ), ++ ( ++ "pulls/index.html", ++ "data-label=\"title\"", ++ "data-label=\"#\"", ++ ), ++ ( ++ "ci/index.html", ++ "data-label=\"step\"", ++ "data-label=\"exit\"", ++ ), ++ ] { ++ let html = std::fs::read_to_string(out.join(page)).expect(page); ++ let a = html.find(first).expect("first cell"); ++ let b = html.find(second).expect("second cell"); ++ assert!(a < b, "{page}: card DOM title precedes meta"); ++ } + let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks"); + assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper"); ++ let title_pos = tasks.find("Card task").expect("task title"); ++ let meta_pos = tasks.find("cccccccc").expect("task meta"); ++ assert!(title_pos < meta_pos, "tasks: title precedes meta"); + assert!(tasks.contains("todo (1)"), "tasks group kept"); + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues"); + assert!( +diff --git a/src/releases.rs b/src/releases.rs +index 68c050b..cceb905 100644 +--- a/src/releases.rs ++++ b/src/releases.rs +@@ -395,10 +395,10 @@ pub fn render_loaded( + if releases.is_empty() { + body.push_str("

No releases yet.

\n"); + } else { +- body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); ++ body.push_str("
versiontitlecreatedartifacts
\n\n\n"); + for rel in releases { + body.push_str(&format!( +- "\n", ++ "\n", + v = esc(&rel.version), + ve = esc(&rel.version), + t = esc(rel.display_title()), +@@ -584,5 +584,9 @@ mod tests { + "label {label}" + ); + } ++ // m6/cards2 (f0rge #47): card DOM order is title-first. ++ let title = index.find("data-label=\"title\"").expect("title cell"); ++ let ver = index.find("data-label=\"version\"").expect("version cell"); ++ assert!(title < ver, "card DOM: title precedes version"); + } + } +diff --git a/static/style.css b/static/style.css +index d645df0..a305df3 100644 +--- a/static/style.css ++++ b/static/style.css +@@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem} + Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above. + Narrow: no scroll (overflow visible), thead clipped for SR only, each + tr becomes a bordered card, each td a labeled flex row via data-label. +- Title cells lead bold without a repeated label; nothing is deleted. */ ++ Title cells lead bold without a repeated label; nothing is deleted. ++ File table exempt (m6/cards2, f0rge #46): dense reference data stays a ++ real table at every width — .file-scroll keeps horizontal scroll and ++ table layout on narrow while every other list table becomes cards. */ + @media (max-width:767px){ +- .table-scroll{overflow-x:visible} +- .table-scroll table,.file-table table{width:100%;min-width:0} +- .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +- .table-scroll tbody,.table-scroll tr{display:block;width:auto} +- .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} +- .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal} +- .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} +- .table-scroll td:last-child{border-bottom:none} +- .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere} +- .table-scroll td.title::before{display:none} +- .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0} +- .table-scroll td.num,.table-scroll td.sha{width:auto} +- .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} +- .table-scroll tbody tr:hover td{background:none} +- .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} ++ .table-scroll:not(.file-scroll){overflow-x:visible} ++ .table-scroll:not(.file-scroll) table{width:100%;min-width:0} ++ .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} ++ .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto} ++ .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} ++ .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal} ++ .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} ++ .table-scroll:not(.file-scroll) td:last-child{border-bottom:none} ++ .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} ++ .table-scroll:not(.file-scroll) td.title::before{display:none} ++ .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0} ++ .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto} ++ .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} ++ .table-scroll:not(.file-scroll) tbody tr:hover td{background:none} ++ .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} ++ .file-scroll{overflow-x:auto} ++ .file-scroll table{width:max-content;min-width:100%} + } +diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs +index c03348b..b43bfcf 100644 +--- a/tests/forge_readers.rs ++++ b/tests/forge_readers.rs +@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() { + + // --- issues index: 2 rendered rows (broken one skipped) --- + let issues = read("issues/index.html"); +- let rows = issues.matches("" + )), + "issue #1 -> older uuid" + ); + assert!( + issues.contains(&format!( +- "" + )), + "issue #2 -> newer uuid" + ); +@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() { + // (4): pulls — same shape + head/base/draft/ci + patch
.
+     let pulls = read("pulls/index.html");
+     assert_eq!(
+-        pulls.matches("
" + )), + "pr #1 -> uuid" + ); +diff --git a/tests/releases_api.rs b/tests/releases_api.rs +index 0c7e472..04e6cee 100644 +--- a/tests/releases_api.rs ++++ b/tests/releases_api.rs +@@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() { + + // --- api counts == html counts (same struct pass) --- + let issues_html = read("issues/index.html") +- .matches("
titleversioncreatedartifacts
{ve}{t}{c}{n}
{t}{ve}{c}{n}
1" ++ "Older issue12" ++ "Newer issue2
1" ++ "Fix the thing1
(PathBuf, String, String) { + } + + fn assert_every_td_truncated(html: &str, page: &str) { ++ // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real ++ // table — trunc + title= kept, no data-label card treatment. ++ assert_every_td_truncated_opts(html, page, page != "index.html"); ++} ++ ++fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) { + let parts: Vec<&str> = html.split(" +Date: Thu, 10 Sep 2026 09:57:21 +0100 +Subject: [PATCH] m6/cards2: file-table exempt, title-first cards + +--- + src/build.rs | 57 ++++++++++++++++++++++++++---------------- + src/commits.rs | 12 ++++++--- + src/forge.rs | 47 +++++++++++++++++++++++++++++----- + src/releases.rs | 8 ++++-- + static/style.css | 37 +++++++++++++++------------ + tests/forge_readers.rs | 10 ++++---- + tests/releases_api.rs | 8 +++--- + tests/truncate.rs | 18 +++++++++++-- + 8 files changed, 137 insertions(+), 60 deletions(-) + +diff --git a/src/build.rs b/src/build.rs +index 6a12e44..f1c0795 100644 +--- a/src/build.rs ++++ b/src/build.rs +@@ -579,13 +579,13 @@ pub fn file_table_html( + history: &std::collections::BTreeMap, + ) -> String { + let mut out = String::from( +- "
\n\ ++ "
\n\ + \n\n", + ); + if let Some((href, label)) = parent { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(label), + esc(href), + esc(label) +@@ -601,8 +601,8 @@ pub fn file_table_html( + let msg_short = truncate_msg(&msg); + if e.is_dir { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(&e.name), + href_esc(&full), + esc(&e.name), +@@ -613,8 +613,8 @@ pub fn file_table_html( + )); + } else { + out.push_str(&format!( +- "\ +- \n", ++ "\ ++ \n", + esc(&e.name), + href_esc(&full), + esc(&e.name), +@@ -1732,28 +1732,41 @@ mod tests { + assert!(sub.contains("title=\""), "title attrs kept"); + } + +- /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards. ++ /// m6/cards2 (f0rge #46): file table stays a real table on all widths — ++ /// no data-label card treatment, scroll wrapper kept, trunc + title kept. + #[test] +- fn file_table_cells_have_data_labels() { ++ fn file_table_stays_table_no_card_labels() { + let css = include_str!("../static/style.css"); +- assert!(css.contains("content:attr(data-label)"), "label rule"); + assert!(css.contains("@media (max-width:767px)"), "narrow query"); ++ assert!( ++ css.contains(".table-scroll:not(.file-scroll)"), ++ "cards scoped off file-scroll" ++ ); ++ assert!( ++ css.contains(".file-scroll{overflow-x:auto}"), ++ "file-scroll keeps h-scroll on narrow" ++ ); + let (_repo, out) = build_fixture(); + for page in ["index.html", "files/sub.html"] { + let html = std::fs::read_to_string(out.join(page)).expect(page); +- let tds = html.matches(" 0, "{page} has cells"); +- assert_eq!( +- html.matches("data-label=").count(), +- tds, +- "{page}: every td labeled" ++ assert!( ++ html.contains( ++ "
NameLast commitAge
{}
{}
{}/{}{}
{}/{}{}
{}{}{}
{}{}{}
" ++ ), ++ "{page}: scroll wrapper kept" ++ ); ++ assert!(!html.contains("data-label="), "{page}: no card labels"); ++ assert!( ++ html.contains("") ++ && html.contains(""), ++ "{page}: headers kept" + ); +- for label in ["Name", "Last commit", "Age"] { +- assert!( +- html.contains(&format!("data-label=\"{label}\"")), +- "{page}: label {label}" +- ); +- } + } + } + +diff --git a/src/commits.rs b/src/commits.rs +index 2324ee1..e3aa5de 100644 +--- a/src/commits.rs ++++ b/src/commits.rs +@@ -40,15 +40,15 @@ pub fn render_loaded( + if log.is_empty() { + body.push_str("

No commits yet.

\n"); + } else { +- body.push_str("
Name") ++ && html.contains("Last commitAge
\n\n\n"); ++ body.push_str("
shasubjectauthordate
\n\n\n"); + for c in log { + body.push_str(&format!( +- "\n", ++ "\n", ++ esc(&c.subject), ++ esc(&c.subject), + esc(&c.sha), + esc(&c.sha), + esc(short_sha(&c.sha)), +- esc(&c.subject), +- esc(&c.subject), + esc(&c.author), + esc(&c.author), + esc(&c.date), +@@ -423,5 +423,9 @@ mod tests { + "label {label}" + ); + } ++ // m6/cards2 (f0rge #47): card DOM order is subject-first. ++ let subj = list.find("data-label=\"subject\"").expect("subject cell"); ++ let sha = list.find("data-label=\"sha\"").expect("sha cell"); ++ assert!(subj < sha, "card DOM: subject precedes sha"); + } + } +diff --git a/src/forge.rs b/src/forge.rs +index 40aa052..d2827b6 100644 +--- a/src/forge.rs ++++ b/src/forge.rs +@@ -998,10 +998,10 @@ fn render_issues_index( + if data.issues.is_empty() { + body.push_str("

No issues yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
subjectshaauthordate
{}{}{}{}
{}{}{}{}
\n\n\n"); ++ body.push_str("
#titlestatecommentscreated
\n\n\n"); + for issue in &data.issues { + body.push_str(&format!( +- "\n", ++ "\n", + n = issue.number, + id = esc(&issue.id), + title = esc(&issue.title), +@@ -1059,14 +1059,14 @@ fn render_pulls_index( + if prs.is_empty() { + body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); + } else { +- body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); ++ body.push_str("
#titlestatehead→basecreated
\n\n\n"); + for pr in prs { + let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); + let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); + body.push_str(&format!( +- "\n", ++ "\n", + n = pr.number, + id = esc(&pr.id), + title = esc(&pr.title), +@@ -1823,11 +1823,19 @@ mod tests { + css.contains("content:attr(data-label)"), + "label via ::before" + ); +- assert!(css.contains(".table-scroll td::before"), "label rule"); + assert!( +- css.contains(".table-scroll td.title::before"), ++ css.contains(".table-scroll:not(.file-scroll) td::before"), ++ "label rule" ++ ); ++ assert!( ++ css.contains(".table-scroll:not(.file-scroll) td.title::before"), + "title leads card" + ); ++ // m6/cards2 (f0rge #46): file table exempt from the card reflow. ++ assert!( ++ css.contains(".file-scroll{overflow-x:auto}"), ++ "file table keeps h-scroll on narrow" ++ ); + assert!( + css.contains("display:block") && css.contains("display:flex"), + "card layout" +@@ -1894,8 +1902,35 @@ mod tests { + ); + } + } ++ // m6/cards2 (f0rge #47): card DOM order is title-first via template ++ // cell order (no CSS order hacks) — desktop column headers match. ++ for (page, first, second) in [ ++ ( ++ "issues/index.html", ++ "data-label=\"title\"", ++ "data-label=\"#\"", ++ ), ++ ( ++ "pulls/index.html", ++ "data-label=\"title\"", ++ "data-label=\"#\"", ++ ), ++ ( ++ "ci/index.html", ++ "data-label=\"step\"", ++ "data-label=\"exit\"", ++ ), ++ ] { ++ let html = std::fs::read_to_string(out.join(page)).expect(page); ++ let a = html.find(first).expect("first cell"); ++ let b = html.find(second).expect("second cell"); ++ assert!(a < b, "{page}: card DOM title precedes meta"); ++ } + let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks"); + assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper"); ++ let title_pos = tasks.find("Card task").expect("task title"); ++ let meta_pos = tasks.find("cccccccc").expect("task meta"); ++ assert!(title_pos < meta_pos, "tasks: title precedes meta"); + assert!(tasks.contains("todo (1)"), "tasks group kept"); + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues"); + assert!( +diff --git a/src/releases.rs b/src/releases.rs +index 68c050b..cceb905 100644 +--- a/src/releases.rs ++++ b/src/releases.rs +@@ -395,10 +395,10 @@ pub fn render_loaded( + if releases.is_empty() { + body.push_str("

No releases yet.

\n"); + } else { +- body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); ++ body.push_str("
versiontitlecreatedartifacts
\n\n\n"); + for rel in releases { + body.push_str(&format!( +- "\n", ++ "\n", + v = esc(&rel.version), + ve = esc(&rel.version), + t = esc(rel.display_title()), +@@ -584,5 +584,9 @@ mod tests { + "label {label}" + ); + } ++ // m6/cards2 (f0rge #47): card DOM order is title-first. ++ let title = index.find("data-label=\"title\"").expect("title cell"); ++ let ver = index.find("data-label=\"version\"").expect("version cell"); ++ assert!(title < ver, "card DOM: title precedes version"); + } + } +diff --git a/static/style.css b/static/style.css +index d645df0..a305df3 100644 +--- a/static/style.css ++++ b/static/style.css +@@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem} + Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above. + Narrow: no scroll (overflow visible), thead clipped for SR only, each + tr becomes a bordered card, each td a labeled flex row via data-label. +- Title cells lead bold without a repeated label; nothing is deleted. */ ++ Title cells lead bold without a repeated label; nothing is deleted. ++ File table exempt (m6/cards2, f0rge #46): dense reference data stays a ++ real table at every width — .file-scroll keeps horizontal scroll and ++ table layout on narrow while every other list table becomes cards. */ + @media (max-width:767px){ +- .table-scroll{overflow-x:visible} +- .table-scroll table,.file-table table{width:100%;min-width:0} +- .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +- .table-scroll tbody,.table-scroll tr{display:block;width:auto} +- .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} +- .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal} +- .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} +- .table-scroll td:last-child{border-bottom:none} +- .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere} +- .table-scroll td.title::before{display:none} +- .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0} +- .table-scroll td.num,.table-scroll td.sha{width:auto} +- .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} +- .table-scroll tbody tr:hover td{background:none} +- .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} ++ .table-scroll:not(.file-scroll){overflow-x:visible} ++ .table-scroll:not(.file-scroll) table{width:100%;min-width:0} ++ .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} ++ .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto} ++ .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} ++ .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal} ++ .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} ++ .table-scroll:not(.file-scroll) td:last-child{border-bottom:none} ++ .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} ++ .table-scroll:not(.file-scroll) td.title::before{display:none} ++ .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0} ++ .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto} ++ .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} ++ .table-scroll:not(.file-scroll) tbody tr:hover td{background:none} ++ .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} ++ .file-scroll{overflow-x:auto} ++ .file-scroll table{width:max-content;min-width:100%} + } +diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs +index c03348b..b43bfcf 100644 +--- a/tests/forge_readers.rs ++++ b/tests/forge_readers.rs +@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() { + + // --- issues index: 2 rendered rows (broken one skipped) --- + let issues = read("issues/index.html"); +- let rows = issues.matches("" + )), + "issue #1 -> older uuid" + ); + assert!( + issues.contains(&format!( +- "" + )), + "issue #2 -> newer uuid" + ); +@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() { + // (4): pulls — same shape + head/base/draft/ci + patch
.
+     let pulls = read("pulls/index.html");
+     assert_eq!(
+-        pulls.matches("
" + )), + "pr #1 -> uuid" + ); +diff --git a/tests/releases_api.rs b/tests/releases_api.rs +index 0c7e472..04e6cee 100644 +--- a/tests/releases_api.rs ++++ b/tests/releases_api.rs +@@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() { + + // --- api counts == html counts (same struct pass) --- + let issues_html = read("issues/index.html") +- .matches("
titleversioncreatedartifacts
{ve}{t}{c}{n}
{t}{ve}{c}{n}
1" ++ "Older issue12" ++ "Newer issue2
1" ++ "Fix the thing1
(PathBuf, String, String) { + } + + fn assert_every_td_truncated(html: &str, page: &str) { ++ // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real ++ // table — trunc + title= kept, no data-label card treatment. ++ assert_every_td_truncated_opts(html, page, page != "index.html"); ++} ++ ++fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) { + let parts: Vec<&str> = html.split(", ) -> String { let mut out = String::from( - "
\n\ + "
\n\ \n\n", ); if let Some((href, label)) = parent { out.push_str(&format!( - "\ - \n", + "\ + \n", esc(label), esc(href), esc(label) @@ -601,8 +601,8 @@ pub fn file_table_html( let msg_short = truncate_msg(&msg); if e.is_dir { out.push_str(&format!( - "\ - \n", + "\ + \n", esc(&e.name), href_esc(&full), esc(&e.name), @@ -613,8 +613,8 @@ pub fn file_table_html( )); } else { out.push_str(&format!( - "\ - \n", + "\ + \n", esc(&e.name), href_esc(&full), esc(&e.name), @@ -1732,28 +1732,41 @@ mod tests { assert!(sub.contains("title=\""), "title attrs kept"); } - /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards. + /// m6/cards2 (f0rge #46): file table stays a real table on all widths — + /// no data-label card treatment, scroll wrapper kept, trunc + title kept. #[test] - fn file_table_cells_have_data_labels() { + fn file_table_stays_table_no_card_labels() { let css = include_str!("../static/style.css"); - assert!(css.contains("content:attr(data-label)"), "label rule"); assert!(css.contains("@media (max-width:767px)"), "narrow query"); + assert!( + css.contains(".table-scroll:not(.file-scroll)"), + "cards scoped off file-scroll" + ); + assert!( + css.contains(".file-scroll{overflow-x:auto}"), + "file-scroll keeps h-scroll on narrow" + ); let (_repo, out) = build_fixture(); for page in ["index.html", "files/sub.html"] { let html = std::fs::read_to_string(out.join(page)).expect(page); - let tds = html.matches(" 0, "{page} has cells"); - assert_eq!( - html.matches("data-label=").count(), - tds, - "{page}: every td labeled" + assert!( + html.contains( + "
NameLast commitAge
{}
{}
{}/{}{}
{}/{}{}
{}{}{}
{}{}{}
" + ), + "{page}: scroll wrapper kept" + ); + assert!(!html.contains("data-label="), "{page}: no card labels"); + assert!( + html.contains("") + && html.contains(""), + "{page}: headers kept" ); - for label in ["Name", "Last commit", "Age"] { - assert!( - html.contains(&format!("data-label=\"{label}\"")), - "{page}: label {label}" - ); - } } } diff --git a/src/commits.rs b/src/commits.rs index 2324ee1..e3aa5de 100644 --- a/src/commits.rs +++ b/src/commits.rs @@ -40,15 +40,15 @@ pub fn render_loaded( if log.is_empty() { body.push_str("

No commits yet.

\n"); } else { - body.push_str("
Name") + && html.contains("Last commitAge
\n\n\n"); + body.push_str("
shasubjectauthordate
\n\n\n"); for c in log { body.push_str(&format!( - "\n", + "\n", + esc(&c.subject), + esc(&c.subject), esc(&c.sha), esc(&c.sha), esc(short_sha(&c.sha)), - esc(&c.subject), - esc(&c.subject), esc(&c.author), esc(&c.author), esc(&c.date), @@ -423,5 +423,9 @@ mod tests { "label {label}" ); } + // m6/cards2 (f0rge #47): card DOM order is subject-first. + let subj = list.find("data-label=\"subject\"").expect("subject cell"); + let sha = list.find("data-label=\"sha\"").expect("sha cell"); + assert!(subj < sha, "card DOM: subject precedes sha"); } } diff --git a/src/forge.rs b/src/forge.rs index f92aabd..01107e8 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -1052,12 +1052,13 @@ fn render_issues_index( if data.issues.is_empty() { body.push_str("

No issues yet. Suggest one — no account needed.

\n"); } else { - body.push_str("
subjectshaauthordate
{}{}{}{}
{}{}{}{}
\n\n\n"); + body.push_str("
#titlestatecommentscreated
\n\n\n"); // m6/sort (#48 + #49): open first (newest-first within), then // non-open (newest-first within). Numbers stay creation-rank. + // m6/cards2 (#46 + #47): title-first column order. for issue in data.issues_in_display_order() { body.push_str(&format!( - "\n", + "\n", n = issue.number, id = esc(&issue.id), title = esc(&issue.title), @@ -1114,17 +1115,18 @@ fn render_pulls_index( if data.prs.is_empty() { body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); } else { - body.push_str("
title#statecommentscreated
{n}{title}{state}{comments}{created}
{title}{n}{state}{comments}{created}
\n\n\n"); + body.push_str("
#titlestatehead→basecreated
\n\n\n"); // m6/sort (#48 + #49): open (incl drafts) first (newest-first // within), then closed/merged (newest-first within). // Numbers stay creation-rank. + // m6/cards2 (#46 + #47): title-first column order. for pr in data.prs_in_display_order() { let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); let lanes_plain = format!("{}→{}", pr.head, pr.base); let draft = if pr.draft { " draft" } else { "" }; let full_title = format!("{}{}", pr.title, draft); body.push_str(&format!( - "\n", + "\n", n = pr.number, id = esc(&pr.id), title = esc(&pr.title), @@ -2073,11 +2075,19 @@ mod tests { css.contains("content:attr(data-label)"), "label via ::before" ); - assert!(css.contains(".table-scroll td::before"), "label rule"); assert!( - css.contains(".table-scroll td.title::before"), + css.contains(".table-scroll:not(.file-scroll) td::before"), + "label rule" + ); + assert!( + css.contains(".table-scroll:not(.file-scroll) td.title::before"), "title leads card" ); + // m6/cards2 (f0rge #46): file table exempt from the card reflow. + assert!( + css.contains(".file-scroll{overflow-x:auto}"), + "file table keeps h-scroll on narrow" + ); assert!( css.contains("display:block") && css.contains("display:flex"), "card layout" @@ -2144,8 +2154,35 @@ mod tests { ); } } + // m6/cards2 (f0rge #47): card DOM order is title-first via template + // cell order (no CSS order hacks) — desktop column headers match. + for (page, first, second) in [ + ( + "issues/index.html", + "data-label=\"title\"", + "data-label=\"#\"", + ), + ( + "pulls/index.html", + "data-label=\"title\"", + "data-label=\"#\"", + ), + ( + "ci/index.html", + "data-label=\"step\"", + "data-label=\"exit\"", + ), + ] { + let html = std::fs::read_to_string(out.join(page)).expect(page); + let a = html.find(first).expect("first cell"); + let b = html.find(second).expect("second cell"); + assert!(a < b, "{page}: card DOM title precedes meta"); + } let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks"); assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper"); + let title_pos = tasks.find("Card task").expect("task title"); + let meta_pos = tasks.find("cccccccc").expect("task meta"); + assert!(title_pos < meta_pos, "tasks: title precedes meta"); assert!(tasks.contains("todo (1)"), "tasks group kept"); let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues"); assert!( diff --git a/src/releases.rs b/src/releases.rs index 68c050b..cceb905 100644 --- a/src/releases.rs +++ b/src/releases.rs @@ -395,10 +395,10 @@ pub fn render_loaded( if releases.is_empty() { body.push_str("

No releases yet.

\n"); } else { - body.push_str("
title#statehead→basecreated
{n}{title}{draft}{state}{lanes}{created}
{title}{draft}{n}{state}{lanes}{created}
\n\n\n"); + body.push_str("
versiontitlecreatedartifacts
\n\n\n"); for rel in releases { body.push_str(&format!( - "\n", + "\n", v = esc(&rel.version), ve = esc(&rel.version), t = esc(rel.display_title()), @@ -584,5 +584,9 @@ mod tests { "label {label}" ); } + // m6/cards2 (f0rge #47): card DOM order is title-first. + let title = index.find("data-label=\"title\"").expect("title cell"); + let ver = index.find("data-label=\"version\"").expect("version cell"); + assert!(title < ver, "card DOM: title precedes version"); } } diff --git a/static/style.css b/static/style.css index d645df0..a305df3 100644 --- a/static/style.css +++ b/static/style.css @@ -229,21 +229,26 @@ details.clonebox:not([open]){padding-bottom:.25rem} Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above. Narrow: no scroll (overflow visible), thead clipped for SR only, each tr becomes a bordered card, each td a labeled flex row via data-label. - Title cells lead bold without a repeated label; nothing is deleted. */ + Title cells lead bold without a repeated label; nothing is deleted. + File table exempt (m6/cards2, f0rge #46): dense reference data stays a + real table at every width — .file-scroll keeps horizontal scroll and + table layout on narrow while every other list table becomes cards. */ @media (max-width:767px){ - .table-scroll{overflow-x:visible} - .table-scroll table,.file-table table{width:100%;min-width:0} - .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} - .table-scroll tbody,.table-scroll tr{display:block;width:auto} - .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} - .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal} - .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} - .table-scroll td:last-child{border-bottom:none} - .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere} - .table-scroll td.title::before{display:none} - .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0} - .table-scroll td.num,.table-scroll td.sha{width:auto} - .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} - .table-scroll tbody tr:hover td{background:none} - .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} + .table-scroll:not(.file-scroll){overflow-x:visible} + .table-scroll:not(.file-scroll) table{width:100%;min-width:0} + .table-scroll:not(.file-scroll) thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} + .table-scroll:not(.file-scroll) tbody,.table-scroll:not(.file-scroll) tr{display:block;width:auto} + .table-scroll:not(.file-scroll) tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0} + .table-scroll:not(.file-scroll) th,.table-scroll:not(.file-scroll) td{white-space:normal} + .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} + .table-scroll:not(.file-scroll) td:last-child{border-bottom:none} + .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} + .table-scroll:not(.file-scroll) td.title::before{display:none} + .table-scroll:not(.file-scroll) td.title{font-weight:700;font-size:1.02rem;min-width:0} + .table-scroll:not(.file-scroll) td.num,.table-scroll:not(.file-scroll) td.sha{width:auto} + .table-scroll:not(.file-scroll) td code,.table-scroll:not(.file-scroll) td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} + .table-scroll:not(.file-scroll) tbody tr:hover td{background:none} + .table-scroll:not(.file-scroll) td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} + .file-scroll{overflow-x:auto} + .file-scroll table{width:max-content;min-width:100%} } diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs index c03348b..b43bfcf 100644 --- a/tests/forge_readers.rs +++ b/tests/forge_readers.rs @@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() { // --- issues index: 2 rendered rows (broken one skipped) --- let issues = read("issues/index.html"); - let rows = issues.matches("" )), "issue #1 -> older uuid" ); assert!( issues.contains(&format!( - "" )), "issue #2 -> newer uuid" ); @@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() { // (4): pulls — same shape + head/base/draft/ci + patch
.
     let pulls = read("pulls/index.html");
     assert_eq!(
-        pulls.matches("
" )), "pr #1 -> uuid" ); diff --git a/tests/releases_api.rs b/tests/releases_api.rs index 0c7e472..04e6cee 100644 --- a/tests/releases_api.rs +++ b/tests/releases_api.rs @@ -154,12 +154,14 @@ fn releases_artifacts_api_agents_feed() { // --- api counts == html counts (same struct pass) --- let issues_html = read("issues/index.html") - .matches("
titleversioncreatedartifacts
{ve}{t}{c}{n}
{t}{ve}{c}{n}
1" + "Older issue12" + "Newer issue2
1" + "Fix the thing1
(PathBuf, String, String) { } fn assert_every_td_truncated(html: &str, page: &str) { + // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real + // table — trunc + title= kept, no data-label card treatment. + assert_every_td_truncated_opts(html, page, page != "index.html"); +} + +fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) { let parts: Vec<&str> = html.split("