land #50-53 finish2 + f0rge state

97f1ca5gl0w

all commits

Message

land #50-53 finish2 + f0rge state

Diff

.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/comments/36c4ec9a-85a5-480c-9dd5-b0d3029442e8.md+6 −0
diff --git a/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/comments/36c4ec9a-85a5-480c-9dd5-b0d3029442e8.md b/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/comments/36c4ec9a-85a5-480c-9dd5-b0d3029442e8.mdnew file mode 100644index 0000000..5a524b7--- /dev/null+++ b/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/comments/36c4ec9a-85a5-480c-9dd5-b0d3029442e8.md@@ -0,0 +1,6 @@1+---2+id: 36c4ec9a-85a5-480c-9dd5-b0d3029442e83+author: null4+created: 2026-09-10T09:17:03.113103006Z5+---6+Covered by PR 765567ca (m6/finish2, joint landing with #50-#53): unified gaps, 48px logo, right footer, clone-summary meta. CI pass. Rubric verified.
.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/issue.md+2 −2
diff --git a/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/issue.md b/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/issue.mdindex e0bb50c..f982a18 100644--- a/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/issue.md+++ b/.forge/issues/16580f39-0650-47a3-b9e2-01aa7390b402/issue.md@@ -1,13 +1,13 @@11 ---22 id: 16580f39-0650-47a3-b9e2-01aa7390b40233 title: Right-align footer text4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:27.020580294Z10-updated: null10+updated: 2026-09-10T09:17:03.118169306Z1111 ---1212 Context: Owner QA — footer text should sit right.1313 Tasks: footer line `text-align:right` (all pages, both themes). Tests: CSS assert. Same branch `m6/finish2`.
.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/comments/9dd86867-dbdc-4a78-ab0b-59e62276f0ab.md+6 −0
diff --git a/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/comments/9dd86867-dbdc-4a78-ab0b-59e62276f0ab.md b/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/comments/9dd86867-dbdc-4a78-ab0b-59e62276f0ab.mdnew file mode 100644index 0000000..9ddc45e--- /dev/null+++ b/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/comments/9dd86867-dbdc-4a78-ab0b-59e62276f0ab.md@@ -0,0 +1,6 @@1+---2+id: 9dd86867-dbdc-4a78-ab0b-59e62276f0ab3+author: null4+created: 2026-09-10T09:17:03.106045954Z5+---6+Covered by PR 765567ca (m6/finish2, joint landing with #50-#53): unified gaps, 48px logo, right footer, clone-summary meta. CI pass. Rubric verified.
.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/issue.md+2 −2
diff --git a/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/issue.md b/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/issue.mdindex a1286ea..ea06a2f 100644--- a/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/issue.md+++ b/.forge/issues/56572b62-9b92-4636-8ec4-3fe9ab477b16/issue.md@@ -1,13 +1,13 @@11 ---22 id: 56572b62-9b92-4636-8ec4-3fe9ab477b1633 title: 2x header logo size4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:27.017596122Z10-updated: null10+updated: 2026-09-10T09:17:03.109470261Z1111 ---1212 Context: Owner QA — header logo too small. Double it.1313 Tasks: .site-logo 24px -> 48px rendered (keep svg vector; header bar grows accordingly; nav stays vertically centered). Mobile: same (bar wraps if needed, no overlap).
.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/comments/c30b5b94-bcf8-41c1-9c0c-dbdde243231d.md+6 −0
diff --git a/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/comments/c30b5b94-bcf8-41c1-9c0c-dbdde243231d.md b/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/comments/c30b5b94-bcf8-41c1-9c0c-dbdde243231d.mdnew file mode 100644index 0000000..c30af76--- /dev/null+++ b/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/comments/c30b5b94-bcf8-41c1-9c0c-dbdde243231d.md@@ -0,0 +1,6 @@1+---2+id: c30b5b94-bcf8-41c1-9c0c-dbdde243231d3+author: null4+created: 2026-09-10T09:17:03.096256333Z5+---6+Covered by PR 765567ca (m6/finish2, joint landing with #50-#53): unified gaps, 48px logo, right footer, clone-summary meta. CI pass. Rubric verified.
.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/issue.md+2 −2
diff --git a/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/issue.md b/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/issue.mdindex c1e9877..702516c 100644--- a/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/issue.md+++ b/.forge/issues/d9c80bee-2789-44b9-8569-bd4aba73fd83/issue.md@@ -1,13 +1,13 @@11 ---22 id: d9c80bee-2789-44b9-8569-bd4aba73fd8333 title: Consistent page-head spacing everywhere4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:27.012974159Z10-updated: null10+updated: 2026-09-10T09:17:03.102052150Z1111 ---1212 Context: Owner QA — vertical rhythm differs between code tab, +button pages, plain pages.1313 Tasks:
.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/comments/1627ed55-6254-4bcb-9d40-ded896290fae.md+6 −0
diff --git a/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/comments/1627ed55-6254-4bcb-9d40-ded896290fae.md b/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/comments/1627ed55-6254-4bcb-9d40-ded896290fae.mdnew file mode 100644index 0000000..581d1d6--- /dev/null+++ b/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/comments/1627ed55-6254-4bcb-9d40-ded896290fae.md@@ -0,0 +1,6 @@1+---2+id: 1627ed55-6254-4bcb-9d40-ded896290fae3+author: null4+created: 2026-09-10T09:17:03.121635502Z5+---6+Covered by PR 765567ca (m6/finish2, joint landing with #50-#53): unified gaps, 48px logo, right footer, clone-summary meta. CI pass. Rubric verified.
.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/issue.md+2 −2
diff --git a/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/issue.md b/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/issue.mdindex 0b79c45..e8d46d1 100644--- a/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/issue.md+++ b/.forge/issues/e705e34d-a03e-48c9-a246-4d607ed95e77/issue.md@@ -1,13 +1,13 @@11 ---22 id: e705e34d-a03e-48c9-a246-4d607ed95e7733 title: Branch+CI into clone summary, drop dupe line4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:27.023552866Z10-updated: null10+updated: 2026-09-10T09:17:03.125988625Z1111 ---1212 Context: Owner QA — "Branch master @ sha / CI: pass" duplicates the clone summary line. Move branch+sha+CI INTO the Clone & download box (visible in collapsed summary: "Clone & download · master @ sha · CI pass"), delete the standalone repo-meta line.1313 Tasks:
.forge/merges/2026-09-10T09-15-48.039993583Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T09-15-48.039993583Z.md b/.forge/merges/2026-09-10T09-15-48.039993583Z.mdnew file mode 100644index 0000000..124aa70--- /dev/null+++ b/.forge/merges/2026-09-10T09-15-48.039993583Z.md@@ -0,0 +1,10 @@1+---2+pr: 765567ca-0017-40cc-ae2b-69dfb741f8373+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T09:15:48.039993583Z7+---8+applied: 329e1b2329f9c9bab919f1fedf7be2901e1015c59+patch: 426172edf8d2e40db9f640f0696db9029f78ebc310+pass.
.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/patch.diff+589 −0
diff --git a/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/patch.diff b/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/patch.diffnew file mode 100644index 0000000..2ee6145--- /dev/null+++ b/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/patch.diff@@ -0,0 +1,589 @@1+From c52a166b8bcf4d1b7e1473fb82f235334e7bc995 Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 10:15:47 +01004+Subject: [PATCH] m6/finish2: spacing, 2x logo, footer right, clone summary5+ meta6+7+---8+ src/build.rs           | 123 ++++++++++++++++++++++++++++++-----------9+ src/commits.rs         |   2 +-10+ src/forge.rs           |   4 +-11+ src/inbox.rs           |   2 +-12+ src/layout.rs          |  48 +++++++++++++++-13+ src/releases.rs        |   2 +-14+ src/wiki.rs            |   2 +-15+ static/style.css       |  15 +++--16+ tests/code_space.rs    |  57 ++++++++++++-------17+ tests/forge_readers.rs |  15 ++++-18+ tests/responsive.rs    |   6 +-19+ 11 files changed, 207 insertions(+), 69 deletions(-)20+21+diff --git a/src/build.rs b/src/build.rs22+index f1c0795..68698e3 10064423+--- a/src/build.rs24++++ b/src/build.rs25+@@ -639,7 +639,9 @@ pub fn file_table_html(26+ /// bundle-first as before (`git clone <url>` first when a clone URL is27+ /// configured, else local bundle) plus the bundle/tarball links with28+ /// sizes and shas. The `<summary>` always shows `Clone & download`29+-/// plus a branch@sha hint.30++/// plus a branch@sha hint. The summary also carries the CI verdict badge31++/// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`32++/// so the standalone repo-meta branch/CI line is gone (no duplication).33+ #[must_use]34+ #[allow(clippy::too_many_arguments)]35+ pub fn clone_block(36+@@ -647,6 +649,7 @@ pub fn clone_block(37+     public_origin: Option<&str>,38+     branch: &str,39+     short_sha: &str,40++    ci_verdict: &str,41+     bundle_size: u64,42+     bundle_sha: Option<&str>,43+     tarball_size: u64,44+@@ -674,11 +677,13 @@ pub fn clone_block(45+             human_size(tarball_size)46+         ),47+     };48++    let badge = crate::forge::verdict_badge(ci_verdict);49+     let mut out = format!(50+         "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\51+-         <summary>Clone &amp; download <span class=\"muted\">{} @ {}</span></summary>\n<div class=\"clone-body\">\n",52++         <summary>Clone &amp; download \u{b7} {} @ {} \u{b7} CI {}</summary>\n<div class=\"clone-body\">\n",53+         esc(branch),54+-        esc(short_sha)55++        esc(short_sha),56++        badge57+     );58+     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {59+         let base = origin.trim_end_matches('/');60+@@ -1071,28 +1076,21 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu61+         .map(|m| m.len())62+         .unwrap_or(0);63+ 64+-    // index.html header: stacked repo-head (name + clone + meta) so narrow65+-    // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.66+-    // No counts line here (m6 open-counts, f0rge #36): nav carries the67+-    // open issues/pulls counts; branch/sha/CI meta stay.68++    // index.html header: stacked repo-head (name + desc + clonebox) so narrow69++    // screens wrap safely. Branch/sha/CI live ONLY in the clone summary70++    // (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`71++    // — the old standalone repo-meta branch/CI line is deleted (no dupe).72++    // No counts line here (m6 open-counts, f0rge #36): nav carries counts.73+     let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));74+     if let Some(d) = &description {75+         body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));76+     }77+-    body.push_str(&format!(78+-        "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",79+-        esc(&branch),80+-        esc(&short_sha)81+-    ));82+-    body.push_str(&format!(83+-        "<p>CI: {}</p>\n",84+-        forge::verdict_badge(forge_data.ci.latest_verdict()),85+-    ));86+     body.push_str(&clone_block(87+         clone_url.as_deref(),88+         public_origin.as_deref(),89+         &branch,90+         &short_sha,91++        forge_data.ci.latest_verdict(),92+         bundle_size,93+         file_hash(&snap.bundle).as_deref(),94+         tarball_size,95+@@ -1284,18 +1282,25 @@ mod tests {96+             "raw HTML escaped"97+         );98+         assert!(index.contains("&lt;script&gt;"), "escaped HTML present");99+-        // Header: branch + short sha, live open f0rge counts, CI verdict badge.100++        // Header (m6 finish, f0rge #53): branch/sha/CI live ONLY in the101++        // clone summary — no standalone repo-meta / CI dupe line.102+         assert!(index.contains("main"), "branch shown");103+         assert!(!index.contains("TODO"), "no TODO markers left");104+         assert!(index.contains("issues (0)"), "nav open issues 0");105+         assert!(index.contains("pulls (0)"), "nav open pulls 0");106+-        assert!(index.contains("repo-meta"), "branch/sha meta kept");107+-        assert!(index.contains("CI:"), "CI verdict shown");108+-        assert!(index.contains("badge"), "CI verdict badge");109++        assert!(!index.contains("repo-meta"), "no dupe repo-meta line");110++        assert!(!index.contains("<p>CI:"), "no dupe CI line");111++        assert!(index.contains("badge"), "CI verdict badge in summary");112++        let summ = index.find("<summary>Clone").expect("summary");113++        let send = index[summ..].find("</summary>").expect("summary end") + summ;114++        let summary = &index[summ..send];115++        assert!(summary.contains("main"), "branch in summary");116++        assert!(summary.contains("CI"), "CI in summary");117++        assert!(summary.contains("badge"), "badge in summary");118+         // f0rge #36: code tab carries no redundant counts line.119+         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");120+-        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;121+-        let head = &index[head_start..head_end];122++        let details_end = index[head_start..].find("</details>").expect("details end") + head_start;123++        let head = &index[head_start..details_end];124+         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {125+             assert!(!head.contains(needle), "code tab drops {needle}");126+         }127+@@ -1362,11 +1367,15 @@ mod tests {128+         let index = std::fs::read_to_string(out.join("index.html")).expect("index");129+         assert!(index.contains("issues (1)"), "nav open issues");130+         assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");131+-        assert!(index.contains("repo-meta"), "branch/sha meta kept");132+-        assert!(index.contains("CI:"), "CI meta kept");133++        assert!(134++            !index.contains("repo-meta"),135++            "no dupe repo-meta (f0rge #53)"136++        );137++        assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");138++        assert!(index.contains("badge"), "CI badge in summary");139+         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");140+-        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;141+-        let head = &index[head_start..head_end];142++        let details_end = index[head_start..].find("</details>").expect("details end") + head_start;143++        let head = &index[head_start..details_end];144+         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {145+             assert!(!head.contains(needle), "code tab drops {needle}");146+         }147+@@ -2041,13 +2050,15 @@ mod tests {148+             let out = mktemp(&format!("out-ci-{verdict}"));149+             run_build(&repo, &out, &opts()).expect("build");150+             let index = std::fs::read_to_string(out.join("index.html")).expect("index");151+-            let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");152+-            let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;153+-            let head = &index[head_start..head_end];154++            let summ = index.find("<summary>Clone").expect("summary");155++            let send = index[summ..].find("</summary>").expect("summary end") + summ;156++            let summary = &index[summ..send];157+             assert!(158+-                head.contains(&format!("badge {verdict}")),159+-                "code head carries badge {verdict}"160++                summary.contains(&format!("badge {verdict}")),161++                "clone summary carries badge {verdict}"162+             );163++            assert!(!index.contains("repo-meta"), "no dupe meta");164++            assert!(!index.contains("<p>CI:"), "no dupe CI line");165+         }166+     }167+ 168+@@ -2249,4 +2260,54 @@ mod tests {169+             "ignored still in raw"170+         );171+     }172++173++    #[test]174++    fn finish_page_heads_and_clone_summary() {175++        // m6 finish (f0rge #50 + #53): one head pattern everywhere, branch+CI176++        // only in the clone summary, no dupe line.177++        let (_repo, out) = build_fixture();178++        let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);179++        let index = read("index.html");180++        // Code tab: repo-head with h1, summary carries branch @ sha + CI.181++        let head_pos = index.find("<div class=\"repo-head\">").expect("repo-head");182++        assert!(index[head_pos..].contains("<h1>"), "code h1 in repo-head");183++        let s0 = index.find("<summary>Clone").expect("summary");184++        let s1 = index[s0..].find("</summary>").expect("summary end") + s0;185++        let summary = &index[s0..s1];186++        assert!(summary.contains("Clone &amp; download"), "summary label");187++        assert!(summary.contains("main"), "branch in summary");188++        assert!(summary.contains("CI"), "CI in summary");189++        assert!(summary.contains("badge"), "CI badge in summary");190++        assert!(!index.contains("repo-meta"), "no dupe repo-meta");191++        assert!(!index.contains("<p>CI:"), "no dupe CI line");192++        // Branch appears in summary; outside details it is not repeated as193++        // a meta line (description/counts carry no branch text here).194++        let details_pos = index.find("<details class=\"clonebox").expect("details");195++        assert!(196++            s0 > head_pos && s0 >= details_pos,197++            "summary inside code head"198++        );199++        // Every index/Form page opens with the same page-head h1 row.200++        for (rel, h1) in [201++            ("issues/index.html", "<h1>Issues</h1>"),202++            ("pulls/index.html", "<h1>Pulls</h1>"),203++            ("commits.html", "<h1>Commits</h1>"),204++            ("tasks/index.html", "<h1>Tasks</h1>"),205++            ("ci/index.html", "<h1>CI</h1>"),206++            ("releases/index.html", "<h1>Releases</h1>"),207++            ("wiki/index.html", "<h1>Wiki</h1>"),208++            ("new-issue.html", "<h1>Suggest an issue</h1>"),209++            ("new-pr.html", "<h1>Suggest a change</h1>"),210++        ] {211++            let html = read(rel);212++            let hpos = html.find("<div class=\"page-head\">").expect(rel);213++            let h1pos = html.find(h1).unwrap_or_else(|| panic!("{rel} missing h1"));214++            let close = html[hpos..].find("</div>").expect("close") + hpos;215++            assert!(h1pos > hpos && h1pos < close, "{rel}: h1 inside page-head");216++        }217++        // Agents guide renders its markdown h1; spacing comes from the218++        // shared `main>h1:first-child` gap-var rule (CSS assert in layout).219++        let agents = read("agents.html");220++        assert!(agents.contains("<h1>"), "agents h1");221++    }222+ }223+diff --git a/src/commits.rs b/src/commits.rs224+index e3aa5de..67872bd 100644225+--- a/src/commits.rs226++++ b/src/commits.rs227+@@ -36,7 +36,7 @@ pub fn render_loaded(228+     snap_sha: &str,229+     description: Option<&str>,230+ ) -> Result<usize, String> {231+-    let mut body = String::from("<h1>Commits</h1>\n");232++    let mut body = String::from("<div class=\"page-head\"><h1>Commits</h1></div>\n");233+     if log.is_empty() {234+         body.push_str("<p class=\"muted\">No commits yet.</p>\n");235+     } else {236+diff --git a/src/forge.rs b/src/forge.rs237+index 01107e8..6044b16 100644238+--- a/src/forge.rs239++++ b/src/forge.rs240+@@ -1161,7 +1161,7 @@ fn render_tasks_index(241+     short_sha: &str,242+     description: Option<&str>,243+ ) -> String {244+-    let mut body = String::from("<h1>Tasks</h1>\n");245++    let mut body = String::from("<div class=\"page-head\"><h1>Tasks</h1></div>\n");246+     if tasks.is_empty() {247+         body.push_str("<p class=\"muted\">No tasks yet.</p>\n");248+     } else {249+@@ -1213,7 +1213,7 @@ fn render_ci_index(250+     short_sha: &str,251+     description: Option<&str>,252+ ) -> String {253+-    let mut body = String::from("<h1>CI</h1>\n");254++    let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");255+     body.push_str(&meta_row("runner:", &esc(&ci.runner)));256+     if ci.steps.is_empty() {257+         body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");258+diff --git a/src/inbox.rs b/src/inbox.rs259+index ecceb1d..a6cf081 100644260+--- a/src/inbox.rs261++++ b/src/inbox.rs262+@@ -619,7 +619,7 @@ fn form_page(263+     description: Option<&str>,264+ ) -> String {265+     let body = format!(266+-        "<h1>{heading}</h1>\n<p>{intro}</p>\n\267++        "<div class=\"page-head\"><h1>{heading}</h1></div>\n<p>{intro}</p>\n\268+          <p>What happens: your text goes to a private inbox only the maintainer reads — \269+          never published on this site. No account needed. The server answers a one-time \270+          ack id and shows nothing back.</p>\n\271+diff --git a/src/layout.rs b/src/layout.rs272+index 096a227..70482a2 100644273+--- a/src/layout.rs274++++ b/src/layout.rs275+@@ -293,7 +293,7 @@ fn page_site_inner(276+ <html lang=\"en\">\n\277+ <head>\n{head}</head>\n\278+ <body>\n\279+-<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"96\" height=\"32\"></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav></div></header>\n\280++<header class=\"site-header\"><div class=\"wrap header-bar\"><a class=\"brand-lockup\" href=\"/\"><img class=\"site-logo\" src=\"/logo.svg\" alt=\"hu13 logo\" width=\"48\" height=\"48\"></a><span class=\"sep\" aria-hidden=\"true\">•</span><nav class=\"site-nav nav-scroll\" aria-label=\"Site\"><div class=\"nav-scroll-row\">{nav_active}</div></nav></div></header>\n\281+ <main class=\"wrap\">\n{body}\n</main>\n\282+ {footer}\n\283+ </body>\n\284+@@ -583,6 +583,52 @@ mod tests {285+         assert!(!provenance_footer(Some("main"), Some("")).contains('@'));286+     }287+ 288++    #[test]289++    fn finish_gap_vars_logo_footer() {290++        // m6 finish (f0rge #50 + #51 + #52): unified head gaps via vars,291++        // 48px logo, right footer — all in one CSS + header assert.292++        let css = include_str!("../static/style.css");293++        // #50: single vars, mobile tighter (.75/1rem) via :root, desktop294++        // looser (1.25/1.5rem) via the existing min-width breakpoint.295++        assert!(css.contains("--gap-head"), "gap-head var");296++        assert!(css.contains("--gap-content"), "gap-content var");297++        assert!(css.contains("--gap-head:.75rem"), "mobile head gap");298++        assert!(css.contains("--gap-content:1rem"), "mobile content gap");299++        assert!(css.contains("--gap-head:1.25rem"), "desktop head gap");300++        assert!(css.contains("--gap-content:1.5rem"), "desktop content gap");301++        assert!(302++            css.contains(".page-head") && css.contains("margin-top:var(--gap-head)"),303++            "page-head uses gap-head"304++        );305++        assert!(306++            css.contains(".page-head") && css.contains("margin-bottom:var(--gap-content)"),307++            "page-head uses gap-content"308++        );309++        assert!(310++            css.contains(".repo-head") && css.contains("margin-top:var(--gap-head)"),311++            "repo-head uses gap-head"312++        );313++        assert!(314++            css.contains("main>h1:first-child") && css.contains("margin-top:var(--gap-head)"),315++            "bare h1 shares nav->head gap"316++        );317++        // #51: 48px logo (was 24px / 1.5rem), vector kept, header centers.318++        assert!(css.contains(".site-logo"), "logo rule");319++        assert!(css.contains("width:3rem"), "logo 48px width");320++        assert!(css.contains("height:3rem"), "logo 48px height");321++        assert!(!css.contains("width:1.5rem"), "old 24px gone");322++        let html = page("t", "<nav></nav>", "<p>b</p>");323++        assert!(324++            html.contains("width=\"48\" height=\"48\""),325++            "header img 48px attrs"326++        );327++        // #52: footer provenance sits right, both themes (pure CSS var block).328++        assert!(329++            css.contains(".footer-provenance") && css.contains("text-align:right"),330++            "footer right-aligned"331++        );332++    }333++334+     #[test]335+     fn provenance_footer_names_snapshot_and_escapes() {336+         // m6 footer-line (f0rge #41): build pages name branch @ sha;337+diff --git a/src/releases.rs b/src/releases.rs338+index cceb905..03ca8e5 100644339+--- a/src/releases.rs340++++ b/src/releases.rs341+@@ -391,7 +391,7 @@ pub fn render_loaded(342+     description: Option<&str>,343+ ) -> Result<(), String> {344+     let root = repo.join(".forge").join("releases");345+-    let mut body = String::from("<h1>Releases</h1>\n");346++    let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");347+     if releases.is_empty() {348+         body.push_str("<p class=\"muted\">No releases yet.</p>\n");349+     } else {350+diff --git a/src/wiki.rs b/src/wiki.rs351+index 746c229..8e332d5 100644352+--- a/src/wiki.rs353++++ b/src/wiki.rs354+@@ -275,7 +275,7 @@ pub fn render(355+     let wiki_dir = out.join("wiki");356+     std::fs::create_dir_all(&wiki_dir).map_err(|e| format!("hu13: create wiki dir: {e}"))?;357+ 358+-    let mut body = String::from("<h1>Wiki</h1>\n");359++    let mut body = String::from("<div class=\"page-head\"><h1>Wiki</h1></div>\n");360+     if pages.is_empty() {361+         body.push_str("<p class=\"muted\">No wiki pages yet.</p>\n");362+     } else {363+diff --git a/static/style.css b/static/style.css364+index a305df3..a75e481 100644365+--- a/static/style.css366++++ b/static/style.css367+@@ -16,6 +16,8 @@368+   --border:#292e42;369+   --visited:#9aa5ce;370+   --btn-fg:#16161e;371++  --gap-head:.75rem;372++  --gap-content:1rem;373+   --diff-add:#9ece6a;374+   --diff-add-bg:#233328;375+   --diff-del:#f7768e;376+@@ -68,7 +70,7 @@ main{padding:1.25rem 0 3rem;min-width:0}377+    CSS margin-top (never <br> spacers); the base stays tight for 360px378+    and wider screens get a touch more. Title is explicit 2rem so it379+    reads as the page title on every viewport. */380+-.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}381++.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:var(--gap-head);margin-bottom:var(--gap-content)}382+ .repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}383+ .clone,.repo-meta{margin:.25rem 0;min-width:0}384+ .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}385+@@ -123,7 +125,7 @@ footer.site-footer{border-top:1px solid var(--border)}386+ footer.site-footer .wrap{padding:1rem}387+ /* Provenance line (m6/footer-line, f0rge #41): the single muted line388+    inside the existing footer block — small, no extra whitespace. */389+-footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}390++footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem;text-align:right}391+ /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.392+    Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/393+    .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav394+@@ -132,7 +134,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}395+ .site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}396+ .brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}397+ .brand-lockup:visited{color:var(--fg)}398+-.site-logo{display:block;width:1.5rem;height:1.5rem;flex:none}399++.site-logo{display:block;width:3rem;height:3rem;flex:none}400+ .hu13-word{font-weight:700;color:var(--blue)}401+ .sep{flex:none;color:var(--muted)}402+ .site-header .site-nav.nav-scroll{flex:1 1 auto;min-width:0;background:none;border-bottom:none;overflow-x:auto;scroll-snap-type:none;-webkit-overflow-scrolling:touch}403+@@ -144,7 +146,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}404+ @media (min-width:768px){405+   .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}406+   .site-nav .wrap{gap:.25rem 1.25rem}407+-  .repo-head{margin-top:1.25rem}408++  :root{--gap-head:1.25rem;--gap-content:1.5rem}409+   main{padding:1.5rem 0 3rem}410+ }411+ /* Code tab two columns (m6/code-cols, f0rge #45): files + README share412+@@ -160,7 +162,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}413+   .cols-code{grid-template-columns:minmax(0,7fr) minmax(0,5fr)}414+ }415+ /* Code conventions (m6, f0rge #20): breadcrumbs, file meta, line anchors, clone block. */416+-.crumbs{margin:.5rem 0;font-size:.95rem}417++.crumbs{margin:var(--gap-head) 0 .5rem;font-size:.95rem}418+ .crumbs a{color:var(--cyan);text-decoration:none}419+ .crumbs a:hover,.crumbs a:focus{text-decoration:underline}420+ .file-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem 1rem;margin:.5rem 0;padding:.5rem .75rem;background:var(--bg-alt);border:1px solid var(--border)}421+@@ -195,8 +197,9 @@ a.ln:hover,a.ln:focus{text-decoration:underline}422+ .table-scroll td.trunc-title,.trunc-title{max-width:60ch}423+ /* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +424+    button share one flex row; wrap allowed on 360px but stays adjacent. */425+-.page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}426++.page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem;margin-top:var(--gap-head);margin-bottom:var(--gap-content)}427+ .page-head h1{margin:0}428++main>h1:first-child{margin-top:var(--gap-head);margin-bottom:var(--gap-content)}429+ .page-head .btn-new{flex:none}430+ 431+ /* Code-gitea (m6, f0rge #33 + #34): file TABLE, language bar, clone details. */432+diff --git a/tests/code_space.rs b/tests/code_space.rs433+index 7d73a3a..9e01e8c 100644434+--- a/tests/code_space.rs435++++ b/tests/code_space.rs436+@@ -93,22 +93,26 @@ fn parse_rem(value: &str) -> Option<f32> {437+ #[test]438+ fn repo_head_spacing_is_css_margin_not_br_tags() {439+     // Structure assert: the code tab keeps its stacked repo-head440+-    // (name + meta + clone block) with air from CSS margin — no `<br>`441+-    // spacers between the navbar and the repo-head.442++    // (name + clone summary with branch/CI) with air from CSS margin —443++    // no `<br>` spacers between the navbar and the repo-head.444++    // (m6 finish, f0rge #53: branch/sha/CI live ONLY in the clone summary.)445+     let out = build_fixture();446+     let index = std::fs::read_to_string(out.join("index.html")).expect("index");447+     let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");448+-    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;449+-    let head = &index[head_start..head_end];450++    let details_end = index[head_start..].find("</details>").expect("details end") + head_start;451++    let head = &index[head_start..details_end];452+     assert!(head.contains("<h1>"), "repo-head keeps its title");453+     assert!(454+         !head.contains("<br"),455+         "spacing is CSS margin, not <br> tags"456+     );457+-    assert!(458+-        head.contains("repo-meta"),459+-        "branch/sha meta line kept inside repo-head"460+-    );461++    assert!(!index.contains("repo-meta"), "no dupe repo-meta line");462++    assert!(!index.contains("<p>CI:"), "no dupe CI line");463++    let s0 = index.find("<summary>Clone").expect("summary");464++    let s1 = index[s0..].find("</summary>").expect("summary end") + s0;465++    let summary = &index[s0..s1];466++    assert!(summary.contains("main"), "branch in summary");467++    assert!(summary.contains("CI"), "CI in summary");468+     // The header bar ends (</header>) before <main> carries the repo-head:469+     // the break between nav and repo-head is a block boundary + margin.470+     let header_end = index.find("</header>").expect("header close");471+@@ -122,13 +126,18 @@ fn repo_head_spacing_is_css_margin_not_br_tags() {472+         "no spacer tags between navbar and repo-head"473+     );474+ 475+-    // CSS assert: `.repo-head` carries the top margin.476++    // CSS assert: `.repo-head` carries the top margin via --gap-head.477+     let css = css();478+     let base = css.split("@media (min-width").next().expect("base css");479+     let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");480+     assert!(481+-        parse_rem(&margin).is_some_and(|v| v > 0.0),482+-        "repo-head margin-top is positive breathing room, got {margin:?}"483++        margin.contains("var(--gap-head)"),484++        "repo-head margin-top uses --gap-head, got {margin:?}"485++    );486++    let gap = rule_value(base, ":root{", "--gap-head").expect("gap-head var");487++    assert!(488++        parse_rem(&gap).is_some_and(|v| v > 0.0),489++        "gap-head is positive breathing room, got {gap:?}"490+     );491+ }492+ 493+@@ -154,15 +163,17 @@ fn repo_head_title_reads_as_page_title() {494+ #[test]495+ fn mobile_stays_tight_wider_screens_get_more() {496+     // 360px must not drown in whitespace: the base (pre-media-query)497+-    // margin stays modest; >=768px may open up a touch more.498++    // --gap-head stays modest; >=768px may open up a touch more.499++    // (m6 finish, f0rge #50: gaps ride on --gap-head/--gap-content vars.)500+     let css = css();501++    assert!(css.contains("--gap-head"), "gap-head var");502++    assert!(css.contains("--gap-content"), "gap-content var");503+     let base = css.split("@media (min-width").next().expect("base css");504+-    let base_margin =505+-        rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");506+-    let base_rem = parse_rem(&base_margin).expect("base margin in rem");507++    let base_gap = rule_value(base, ":root{", "--gap-head").expect("base gap-head");508++    let base_rem = parse_rem(&base_gap).expect("base gap in rem");509+     assert!(510+         base_rem <= 1.0,511+-        "mobile margin-top stays tight (<=1rem), got {base_margin:?}"512++        "mobile gap-head stays tight (<=1rem), got {base_gap:?}"513+     );514+     let desktop = css515+         .split("@media (min-width:768px)")516+@@ -170,15 +181,19 @@ fn mobile_stays_tight_wider_screens_get_more() {517+         .expect("768px breakpoint");518+     // Only the current breakpoint block: cut before the next @media.519+     let block = desktop.split("@media").next().expect("768 block");520+-    let wide_margin =521+-        rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");522+-    let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");523++    let wide_gap = rule_value(block, ":root{", "--gap-head").expect("wide gap-head");524++    let wide_rem = parse_rem(&wide_gap).expect("wide gap in rem");525+     assert!(526+         wide_rem >= base_rem,527+-        "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"528++        "wider screens keep at least base air ({wide_gap:?} vs {base_gap:?})"529+     );530+     assert!(531+         wide_rem <= 2.0,532+-        "even wide stays modest (<=2rem), got {wide_margin:?}"533++        "even wide stays modest (<=2rem), got {wide_gap:?}"534++    );535++    // Heads share the var (single pattern, not per-page px).536++    assert!(537++        base.contains("margin-top:var(--gap-head)"),538++        "heads use gap-head var"539+     );540+ }541+diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs542+index b43bfcf..27762ef 100644543+--- a/tests/forge_readers.rs544++++ b/tests/forge_readers.rs545+@@ -316,10 +316,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {546+     let index = read("index.html");547+     assert!(index.contains("issues (1)"), "nav open issue count");548+     assert!(index.contains("pulls (1)"), "nav open pull count");549+-    assert!(index.contains("repo-meta"), "branch/sha meta kept");550++    assert!(551++        !index.contains("repo-meta"),552++        "no dupe repo-meta (f0rge #53)"553++    );554++    assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");555++    let summ = index.find("<summary>Clone").expect("summary");556++    let send = index[summ..].find("</summary>").expect("summary end") + summ;557++    let summary = &index[summ..send];558++    assert!(summary.contains("CI"), "CI in summary");559++    assert!(summary.contains("badge fail"), "fail badge in summary");560+     let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");561+-    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;562+-    let head = &index[head_start..head_end];563++    let details_end = index[head_start..].find("</details>").expect("details end") + head_start;564++    let head = &index[head_start..details_end];565+     for needle in ["issues (", "pulls (", "tasks (", "releases ("] {566+         assert!(!head.contains(needle), "code tab drops {needle}");567+     }568+diff --git a/tests/responsive.rs b/tests/responsive.rs569+index 85b664e..9879f70 100644570+--- a/tests/responsive.rs571++++ b/tests/responsive.rs572+@@ -233,9 +233,13 @@ fn touch_targets_and_stacking_header() {573+     }574+     let out = build_fixture();575+     let index = std::fs::read_to_string(out.join("index.html")).expect("index");576+-    for needle in ["repo-head", "cols", "repo-meta"] {577++    for needle in ["repo-head", "cols", "clonebox"] {578+         assert!(index.contains(needle), "index.html has {needle}");579+     }580++    assert!(581++        !index.contains("repo-meta"),582++        "no dupe repo-meta (f0rge #53)"583++    );584+ }585+ 586+ #[test]587+-- 588+2.55.0589+
.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/pr.md+12 −0
diff --git a/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/pr.md b/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/pr.mdnew file mode 100644index 0000000..3b4415d--- /dev/null+++ b/.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/pr.md@@ -0,0 +1,12 @@1+---2+id: 765567ca-0017-40cc-ae2b-69dfb741f8373+title: m6/finish24+state: merged5+base: master6+head: m6/finish27+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T09:15:48.003371605Z11+updated: 2026-09-10T09:16:24.952414159Z12+---