land #39 no-scroll + f0rge state

d42c202gl0w

all commits

Message

land #39 no-scroll + f0rge state

Diff

.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/comments/6ce79818-f358-42f1-ba9b-6214661134d9.md+6 −0
diff --git a/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/comments/6ce79818-f358-42f1-ba9b-6214661134d9.md b/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/comments/6ce79818-f358-42f1-ba9b-6214661134d9.mdnew file mode 100644index 0000000..4bf3b56--- /dev/null+++ b/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/comments/6ce79818-f358-42f1-ba9b-6214661134d9.md@@ -0,0 +1,6 @@1+---2+id: 6ce79818-f358-42f1-ba9b-6214661134d93+author: null4+created: 2026-09-10T07:40:45.232651367Z5+---6+Merged PR 39589eba (m6/no-scroll): stacked cards under 768px (data-labels, hidden thead, no h-scroll), desktop tables untouched. 205 data-labels verified in dogfood. CI pass. Rubric verified.
.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/issue.md+2 −2
diff --git a/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/issue.md b/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/issue.mdindex 8c8445c..aaf7153 100644--- a/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/issue.md+++ b/.forge/issues/07d21d79-8fba-4c9e-a191-d7475f6336ca/issue.md@@ -1,13 +1,13 @@11 ---22 id: 07d21d79-8fba-4c9e-a191-d7475f6336ca33 title: Replace scroll-tables with stacked cards on mobile4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T07:30:45.791389687Z10-updated: null10+updated: 2026-09-10T07:40:45.235767240Z1111 ---1212 Context: Owner QA — horizontal scroll containers were the compromise; owner wants them GONE on mobile. Study Gitea again (issue list, PR list, file table, commit list) and replace scroll-with-better.1313 Tasks (research first — fetch a Gitea site or work from knowledge, note which):
.forge/merges/2026-09-10T07-39-46.582546361Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T07-39-46.582546361Z.md b/.forge/merges/2026-09-10T07-39-46.582546361Z.mdnew file mode 100644index 0000000..f700c8d--- /dev/null+++ b/.forge/merges/2026-09-10T07-39-46.582546361Z.md@@ -0,0 +1,10 @@1+---2+pr: 39589eba-2219-4c35-9b17-0527275b24be3+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T07:39:46.582546361Z7+---8+applied: eb66841995b73e994ce58b217fe99f8154b62bca9+patch: 5012f2783bff186e41f4c9c679acdd90e1747f3910+pass.
.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/patch.diff+490 −0
diff --git a/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/patch.diff b/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/patch.diffnew file mode 100644index 0000000..c034b50--- /dev/null+++ b/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/patch.diff@@ -0,0 +1,490 @@1+From dacca5a820d372be31d9058ab209055921e63e70 Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 08:39:46 +01004+Subject: [PATCH] m6/no-scroll: stacked cards on mobile, tables on desktop5+6+---7+ src/build.rs           |  39 +++++++++++++---8+ src/commits.rs         |  34 ++++++++++++--9+ src/forge.rs           | 100 +++++++++++++++++++++++++++++++++++++++--10+ src/releases.rs        |  46 ++++++++++++++++++-11+ static/style.css       |  21 +++++++++12+ tests/forge_readers.rs |  10 ++---13+ tests/releases_api.rs  |  10 ++---14+ tests/responsive.rs    |   8 ++--15+ 8 files changed, 238 insertions(+), 30 deletions(-)16+17+diff --git a/src/build.rs b/src/build.rs18+index d70dc3f..b0b69ca 10064419+--- a/src/build.rs20++++ b/src/build.rs21+@@ -584,8 +584,8 @@ pub fn file_table_html(22+     );23+     if let Some((href, label)) = parent {24+         out.push_str(&format!(25+-            "<tr class=\"parent\"><td><a href=\"{}\">{}</a></td>\26+-             <td></td><td></td></tr>\n",27++            "<tr class=\"parent\"><td data-label=\"Name\"><a href=\"{}\">{}</a></td>\28++             <td data-label=\"Last commit\"></td><td data-label=\"Age\"></td></tr>\n",29+             esc(href),30+             esc(label)31+         ));32+@@ -600,8 +600,8 @@ pub fn file_table_html(33+         let msg_short = truncate_msg(&msg);34+         if e.is_dir {35+             out.push_str(&format!(36+-                "<tr class=\"dir\"><td><a href=\"/files/{}.html\">{}/</a></td>\37+-                 <td class=\"title\">{}</td><td>{}</td></tr>\n",38++                "<tr class=\"dir\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}/</a></td>\39++                 <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",40+                 href_esc(&full),41+                 esc(&e.name),42+                 esc(&msg_short),43+@@ -609,8 +609,8 @@ pub fn file_table_html(44+             ));45+         } else {46+             out.push_str(&format!(47+-                "<tr class=\"file\"><td><a href=\"/files/{}.html\">{}</a></td>\48+-                 <td class=\"title\">{}</td><td>{}</td></tr>\n",49++                "<tr class=\"file\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}</a></td>\50++                 <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",51+                 href_esc(&full),52+                 esc(&e.name),53+                 esc(&msg_short),54+@@ -1651,7 +1651,32 @@ mod tests {55+             "breadcrumb kept on subdir"56+         );57+         // Message + age columns exist even when history is empty-ish.58+-        assert!(sub.contains("<td class=\"title\">"), "message cell");59++        assert!(sub.contains("<td class=\"title\""), "message cell");60++    }61++62++    /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.63++    #[test]64++    fn file_table_cells_have_data_labels() {65++        let css = include_str!("../static/style.css");66++        assert!(css.contains("content:attr(data-label)"), "label rule");67++        assert!(css.contains("@media (max-width:767px)"), "narrow query");68++        let (_repo, out) = build_fixture();69++        for page in ["index.html", "files/sub.html"] {70++            let html = std::fs::read_to_string(out.join(page)).expect(page);71++            let tds = html.matches("<td").count();72++            assert!(tds > 0, "{page} has cells");73++            assert_eq!(74++                html.matches("data-label=").count(),75++                tds,76++                "{page}: every td labeled"77++            );78++            for label in ["Name", "Last commit", "Age"] {79++                assert!(80++                    html.contains(&format!("data-label=\"{label}\"")),81++                    "{page}: label {label}"82++                );83++            }84++        }85+     }86+ 87+     #[test]88+diff --git a/src/commits.rs b/src/commits.rs89+index d5fa33e..7075113 10064490+--- a/src/commits.rs91++++ b/src/commits.rs92+@@ -40,7 +40,7 @@ pub fn render_loaded(93+         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");94+         for c in log {95+             body.push_str(&format!(96+-                "<tr><td class=\"sha\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title\">{}</td><td>{}</td><td>{}</td></tr>\n",97++                "<tr><td class=\"sha\" data-label=\"sha\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title\" data-label=\"subject\">{}</td><td data-label=\"author\">{}</td><td data-label=\"date\">{}</td></tr>\n",98+                 esc(&c.sha),99+                 esc(short_sha(&c.sha)),100+                 esc(&c.subject),101+@@ -342,8 +342,8 @@ mod tests {102+             list.contains("<div class=\"table-scroll\"><table>"),103+             "wrapper"104+         );105+-        assert!(list.contains("<td class=\"title\">"), "title cell");106+-        assert!(list.contains("<td class=\"sha\">"), "sha cell");107++        assert!(list.contains("<td class=\"title\""), "title cell");108++        assert!(list.contains("<td class=\"sha\""), "sha cell");109+         assert!(list.contains(&long_subject), "long subject kept");110+         let sha = shas(&dir)[0].clone();111+         assert!(112+@@ -368,4 +368,32 @@ mod tests {113+         let list = std::fs::read_to_string(out.join("commits.html")).expect("list");114+         assert!(list.contains("No commits yet."));115+     }116++117++    /// m6/no-scroll (f0rge #39): commit rows carry data-labels for cards.118++    #[test]119++    fn commit_cells_have_data_labels() {120++        let css = include_str!("../static/style.css");121++        assert!(css.contains("content:attr(data-label)"), "label rule");122++        assert!(css.contains("@media (max-width:767px)"), "narrow query");123++        let dir = mktemp("labels");124++        git(&dir, &["init", "-b", "main"]);125++        git(&dir, &["config", "user.name", "hu13-test"]);126++        git(&dir, &["config", "user.email", "[email protected]"]);127++        git(&dir, &["config", "commit.gpgsign", "false"]);128++        std::fs::write(dir.join("x.txt"), "x\n").unwrap();129++        git(&dir, &["add", "-A"]);130++        git(&dir, &["commit", "-m", "labeled subject"]);131++        let out = mktemp("out");132++        render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");133++        let list = std::fs::read_to_string(out.join("commits.html")).expect("list");134++        let tds = list.matches("<td").count();135++        assert!(tds > 0, "has cells");136++        assert_eq!(list.matches("data-label=").count(), tds, "every td labeled");137++        for label in ["sha", "subject", "author", "date"] {138++            assert!(139++                list.contains(&format!("data-label=\"{label}\"")),140++                "label {label}"141++            );142++        }143++    }144+ }145+diff --git a/src/forge.rs b/src/forge.rs146+index fe2c0e4..b41ed7a 100644147+--- a/src/forge.rs148++++ b/src/forge.rs149+@@ -986,7 +986,7 @@ fn render_issues_index(150+         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");151+         for issue in &data.issues {152+             body.push_str(&format!(153+-                "<tr><td class=\"num\">{n}</td><td class=\"title\"><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",154++                "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{id}.html\">{title}</a></td><td data-label=\"state\">{state}</td><td data-label=\"comments\">{comments}</td><td data-label=\"created\">{created}</td></tr>\n",155+                 n = issue.number,156+                 id = esc(&issue.id),157+                 title = esc(&issue.title),158+@@ -1045,7 +1045,7 @@ fn render_pulls_index(159+             let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));160+             let draft = if pr.draft { " draft" } else { "" };161+             body.push_str(&format!(162+-                "<tr><td class=\"num\">{n}</td><td class=\"title\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td>{state}</td><td><code>{lanes}</code></td><td>{created}</td></tr>\n",163++                "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td data-label=\"state\">{state}</td><td data-label=\"head→base\"><code>{lanes}</code></td><td data-label=\"created\">{created}</td></tr>\n",164+                 n = pr.number,165+                 id = esc(&pr.id),166+                 title = esc(&pr.title),167+@@ -1143,7 +1143,7 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<168+                 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");169+                 for s in &run.steps {170+                     body.push_str(&format!(171+-                        "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",172++                        "<tr><td data-label=\"step\"><code>{}</code></td><td data-label=\"exit\">{}</td><td data-label=\"duration_ms\">{}</td></tr>\n",173+                         esc(&s.name),174+                         s.exit,175+                         s.duration_ms,176+@@ -1690,7 +1690,7 @@ mod tests {177+                 html.contains("<div class=\"table-scroll\"><table>"),178+                 "{page}: wrapper"179+             );180+-            assert!(html.contains("<td class=\"title\">"), "{page}: title cell");181++            assert!(html.contains("<td class=\"title\""), "{page}: title cell");182+             assert!(html.contains(&long_title), "{page}: long title kept");183+         }184+         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");185+@@ -1750,4 +1750,96 @@ mod tests {186+         assert!(pulls.contains("1 closed"), "pulls closed total");187+         assert!(pulls.contains(" draft"), "draft stays marked");188+     }189++190++    /// m6/no-scroll (f0rge #39): stacked-card reflow on narrow, desktop untouched.191++    /// Research: from knowledge of Gitea mobile (issue/PR lists collapse to192++    /// title-led cards; file table stacks name + meta) — no live fetch here.193++    #[test]194++    fn stacked_cards_no_scroll() {195++        let css = include_str!("../static/style.css");196++        assert!(css.contains("@media (max-width:767px)"), "narrow query");197++        assert!(css.contains("overflow-x:visible"), "no scroll on narrow");198++        assert!(199++            css.contains("content:attr(data-label)"),200++            "label via ::before"201++        );202++        assert!(css.contains(".table-scroll td::before"), "label rule");203++        assert!(204++            css.contains(".table-scroll td.title::before"),205++            "title leads card"206++        );207++        assert!(208++            css.contains("display:block") && css.contains("display:flex"),209++            "card layout"210++        );211++        assert!(css.contains("width:max-content"), "desktop width kept");212++        assert!(213++            css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),214++            "desktop nowrap kept"215++        );216++        let repo = mktemp("cards");217++        write_forge(218++            &repo,219++            "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",220++            "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Card issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",221++        );222++        write_forge(223++            &repo,224++            "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",225++            "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Card pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",226++        );227++        write_forge(228++            &repo,229++            "tasks/cccccccc-cccc-cccc-cccc-cccccccccccc.md",230++            "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Card task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nwork\n",231++        );232++        write_forge(&repo, "ci/ci.env", "runner: local\n");233++        std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();234++        std::fs::write(235++            repo.join(".forge").join("ci/steps/00_test.sh"),236++            "#!/bin/sh\n",237++        )238++        .unwrap();239++        write_forge(240++            &repo,241++            "ci/runs/2026-09-10T00-00-00Z/summary.md",242++            "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 7\n---\nok\n",243++        );244++        let out = mktemp("cards-out");245++        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");246++        for (page, labels) in [247++            (248++                "issues/index.html",249++                vec!["#", "title", "state", "comments", "created"],250++            ),251++            (252++                "pulls/index.html",253++                vec!["#", "title", "state", "head→base", "created"],254++            ),255++            ("ci/index.html", vec!["step", "exit", "duration_ms"]),256++        ] {257++            let html = std::fs::read_to_string(out.join(page)).expect(page);258++            let tds = html.matches("<td").count();259++            assert!(tds > 0, "{page} has cells");260++            assert_eq!(261++                html.matches("data-label=").count(),262++                tds,263++                "{page}: every td labeled"264++            );265++            for label in labels {266++                assert!(267++                    html.contains(&format!("data-label=\"{label}\"")),268++                    "{page}: label {label}"269++                );270++            }271++        }272++        let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");273++        assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");274++        assert!(tasks.contains("todo (1)"), "tasks group kept");275++        let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");276++        assert!(277++            issues.contains("open") && issues.contains("2026-09-10"),278++            "issues data kept in DOM"279++        );280++    }281+ }282+diff --git a/src/releases.rs b/src/releases.rs283+index ad238d9..87493bb 100644284+--- a/src/releases.rs285++++ b/src/releases.rs286+@@ -391,7 +391,7 @@ pub fn render_loaded(287+         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");288+         for rel in releases {289+             body.push_str(&format!(290+-                "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title\">{t}</td><td>{c}</td><td>{n}</td></tr>\n",291++                "<tr><td data-label=\"version\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title\" data-label=\"title\">{t}</td><td data-label=\"created\">{c}</td><td data-label=\"artifacts\">{n}</td></tr>\n",292+                 v = esc(&rel.version),293+                 ve = esc(&rel.version),294+                 t = esc(rel.display_title()),295+@@ -493,7 +493,7 @@ mod tests {296+             index.contains("<div class=\"table-scroll\"><table>"),297+             "wrapper"298+         );299+-        assert!(index.contains("<td class=\"title\">"), "title cell");300++        assert!(index.contains("<td class=\"title\""), "title cell");301+         assert!(index.contains(&long_title), "long title kept");302+     }303+ 304+@@ -510,4 +510,46 @@ mod tests {305+         std::fs::create_dir_all(&dir).unwrap();306+         assert!(load(&dir).is_empty());307+     }308++309++    /// m6/no-scroll (f0rge #39): release rows carry data-labels for cards.310++    #[test]311++    fn release_cells_have_data_labels() {312++        let css = include_str!("../static/style.css");313++        assert!(css.contains("content:attr(data-label)"), "label rule");314++        let rel = Release {315++            version: "v1.2.3".to_string(),316++            title: Some("Labeled".to_string()),317++            created: "2026-09-10T03:00:00Z".to_string(),318++            sha: None,319++            body: "notes\n".to_string(),320++            artifacts: Vec::new(),321++        };322++        let repo = std::env::temp_dir().join(format!(323++            "hu13-rel-labels-{}-{}",324++            std::process::id(),325++            std::time::SystemTime::now()326++                .duration_since(std::time::UNIX_EPOCH)327++                .map(|d| d.as_nanos())328++                .unwrap_or(0)329++        ));330++        std::fs::create_dir_all(&repo).unwrap();331++        let out = repo.join("out");332++        std::fs::create_dir_all(&out).unwrap();333++        render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");334++        let index =335++            std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");336++        let tds = index.matches("<td").count();337++        assert!(tds > 0, "has cells");338++        assert_eq!(339++            index.matches("data-label=").count(),340++            tds,341++            "every td labeled"342++        );343++        for label in ["version", "title", "created", "artifacts"] {344++            assert!(345++                index.contains(&format!("data-label=\"{label}\"")),346++                "label {label}"347++            );348++        }349++    }350+ }351+diff --git a/static/style.css b/static/style.css352+index a09ecf2..2efee6d 100644353+--- a/static/style.css354++++ b/static/style.css355+@@ -183,3 +183,24 @@ details.clonebox{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt)356+ details.clonebox>summary{cursor:pointer;min-height:40px;font-weight:700;color:var(--cyan)}357+ details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}358+ .clone-hint,.clonebox summary .muted{font-weight:400}359++/* Stacked cards on narrow (m6/no-scroll, f0rge #39): Gitea-style reflow.360++   Desktop (>=768px) keeps .table-scroll horizontal scroll untouched above.361++   Narrow: no scroll (overflow visible), thead clipped for SR only, each362++   tr becomes a bordered card, each td a labeled flex row via data-label.363++   Title cells lead bold without a repeated label; nothing is deleted. */364++@media (max-width:767px){365++  .table-scroll{overflow-x:visible}366++  .table-scroll table,.file-table table{width:100%;min-width:0}367++  .table-scroll thead{position:absolute;max-width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}368++  .table-scroll tbody,.table-scroll tr{display:block;width:auto}369++  .table-scroll tr{border:1px solid var(--border);background:var(--bg-alt);margin:0 0 .75rem;padding:.25rem 0}370++  .table-scroll th,.table-scroll td,.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:normal}371++  .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}372++  .table-scroll td:last-child{border-bottom:none}373++  .table-scroll td::before{content:attr(data-label);flex:0 0 6rem;color:var(--muted);font-weight:600;font-size:.8rem;overflow-wrap:anywhere}374++  .table-scroll td.title::before{display:none}375++  .table-scroll td.title{font-weight:700;font-size:1.02rem;min-width:0}376++  .table-scroll td.num,.table-scroll td.sha{width:auto}377++  .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}378++  .table-scroll tbody tr:hover td{background:none}379++}380+diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs381+index d695600..b3aa723 100644382+--- a/tests/forge_readers.rs383++++ b/tests/forge_readers.rs384+@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {385+ 386+     // --- issues index: 2 rendered rows (broken one skipped) ---387+     let issues = read("issues/index.html");388+-    let rows = issues.matches("<tr><td class=\"num\">").count();389++    let rows = issues.matches("<tr><td class=\"num\"").count();390+     assert_eq!(rows, 2, "rendered issues match file-count minus broken");391+     assert!(!issues.contains("No title here"), "broken issue skipped");392+     // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.393+     assert!(394+         issues.contains(&format!(395+-            "<td class=\"num\">1</td><td class=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"396++            "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"397+         )),398+         "issue #1 -> older uuid"399+     );400+     assert!(401+         issues.contains(&format!(402+-            "<td class=\"num\">2</td><td class=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"403++            "<td class=\"num\" data-label=\"#\">2</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"404+         )),405+         "issue #2 -> newer uuid"406+     );407+@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {408+     // (4): pulls — same shape + head/base/draft/ci + patch <pre>.409+     let pulls = read("pulls/index.html");410+     assert_eq!(411+-        pulls.matches("<tr><td class=\"num\">").count(),412++        pulls.matches("<tr><td class=\"num\"").count(),413+         1,414+         "prs match file-count"415+     );416+     assert!(417+         pulls.contains(&format!(418+-            "<td class=\"num\">1</td><td class=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"419++            "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"420+         )),421+         "pr #1 -> uuid"422+     );423+diff --git a/tests/releases_api.rs b/tests/releases_api.rs424+index 9ea9a71..ea32b2c 100644425+--- a/tests/releases_api.rs426++++ b/tests/releases_api.rs427+@@ -110,7 +110,7 @@ fn releases_artifacts_api_agents_feed() {428+ 429+     // --- releases: index + both detail pages ---430+     let index = read("releases/index.html");431+-    assert_eq!(index.matches("<tr><td>").count(), 2, "two release rows");432++    assert_eq!(index.matches("<tr><td").count(), 2, "two release rows");433+     assert!(index.contains("v1.0"), "v1.0 listed");434+     assert!(index.contains("v2.0"), "v2.0 listed");435+ 436+@@ -154,15 +154,15 @@ fn releases_artifacts_api_agents_feed() {437+ 438+     // --- api counts == html counts (same struct pass) ---439+     let issues_html = read("issues/index.html")440+-        .matches("<tr><td class=\"num\">")441++        .matches("<tr><td class=\"num\"")442+         .count();443+     let pulls_html = read("pulls/index.html")444+-        .matches("<tr><td class=\"num\">")445++        .matches("<tr><td class=\"num\"")446+         .count();447+     let commits_html = read("commits.html")448+-        .matches("<tr><td class=\"sha\">")449++        .matches("<tr><td class=\"sha\"")450+         .count();451+-    let releases_html = read("releases/index.html").matches("<tr><td>").count();452++    let releases_html = read("releases/index.html").matches("<tr><td").count();453+     let wiki_html = read("wiki/index.html")454+         .matches("<li><a href=\"/wiki/")455+         .count();456+diff --git a/tests/responsive.rs b/tests/responsive.rs457+index 37b536e..f0c4b2d 100644458+--- a/tests/responsive.rs459++++ b/tests/responsive.rs460+@@ -377,23 +377,23 @@ fn forge_list_tables_scroll_and_wrap_long_titles() {461+     for page in ["issues/index.html", "pulls/index.html"] {462+         let html = read(page);463+         assert!(464+-            html.contains("<td class=\"title\">"),465++            html.contains("<td class=\"title\""),466+             "{page} marks title cells"467+         );468+         assert!(469+             html.contains(&long_title),470+             "{page} keeps the full long title"471+         );472+-        assert!(html.contains("<td class=\"num\">"), "{page} keeps # narrow");473++        assert!(html.contains("<td class=\"num\""), "{page} keeps # narrow");474+     }475+     let commits = read("commits.html");476+     assert!(477+-        commits.contains("<td class=\"sha\">"),478++        commits.contains("<td class=\"sha\""),479+         "commits marks sha cells"480+     );481+     let releases = read("releases/index.html");482+     assert!(483+-        releases.contains("<td class=\"title\">"),484++        releases.contains("<td class=\"title\""),485+         "releases marks title cells"486+     );487+     // Labels from the fixture still list on the issues index.488+-- 489+2.55.0490+
.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/pr.md+12 −0
diff --git a/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/pr.md b/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/pr.mdnew file mode 100644index 0000000..a2f4b3a--- /dev/null+++ b/.forge/prs/39589eba-2219-4c35-9b17-0527275b24be/pr.md@@ -0,0 +1,12 @@1+---2+id: 39589eba-2219-4c35-9b17-0527275b24be3+title: m6/no-scroll4+state: merged5+base: master6+head: m6/no-scroll7+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T07:39:46.551223216Z11+updated: 2026-09-10T07:40:17.142194565Z12+---