m6/finish2: spacing, 2x logo, footer right, clone summary meta

89d1730gl0w

all commits

Message

m6/finish2: spacing, 2x logo, footer right, clone summary meta

Diff

src/build.rs+92 −31
diff --git a/src/build.rs b/src/build.rsindex f1c0795..68698e3 100644--- a/src/build.rs+++ b/src/build.rs@@ -639,7 +639,9 @@ pub fn file_table_html(639639 /// bundle-first as before (`git clone <url>` first when a clone URL is640640 /// configured, else local bundle) plus the bundle/tarball links with641641 /// sizes and shas. The `<summary>` always shows `Clone & download`642-/// plus a branch@sha hint.642+/// plus a branch@sha hint. The summary also carries the CI verdict badge643+/// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`644+/// so the standalone repo-meta branch/CI line is gone (no duplication).643645 #[must_use]644646 #[allow(clippy::too_many_arguments)]645647 pub fn clone_block(@@ -647,6 +649,7 @@ pub fn clone_block(647649     public_origin: Option<&str>,648650     branch: &str,649651     short_sha: &str,652+    ci_verdict: &str,650653     bundle_size: u64,651654     bundle_sha: Option<&str>,652655     tarball_size: u64,@@ -674,11 +677,13 @@ pub fn clone_block(674677             human_size(tarball_size)675678         ),676679     };680+    let badge = crate::forge::verdict_badge(ci_verdict);677681     let mut out = format!(678682         "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\679-         <summary>Clone &amp; download <span class=\"muted\">{} @ {}</span></summary>\n<div class=\"clone-body\">\n",683+         <summary>Clone &amp; download \u{b7} {} @ {} \u{b7} CI {}</summary>\n<div class=\"clone-body\">\n",680684         esc(branch),681-        esc(short_sha)685+        esc(short_sha),686+        badge682687     );683688     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {684689         let base = origin.trim_end_matches('/');@@ -1071,28 +1076,21 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10711076         .map(|m| m.len())10721077         .unwrap_or(0);10731078 1074-    // index.html header: stacked repo-head (name + clone + meta) so narrow1075-    // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.1076-    // No counts line here (m6 open-counts, f0rge #36): nav carries the1077-    // open issues/pulls counts; branch/sha/CI meta stay.1079+    // index.html header: stacked repo-head (name + desc + clonebox) so narrow1080+    // screens wrap safely. Branch/sha/CI live ONLY in the clone summary1081+    // (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`1082+    // — the old standalone repo-meta branch/CI line is deleted (no dupe).1083+    // No counts line here (m6 open-counts, f0rge #36): nav carries counts.10781084     let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));10791085     if let Some(d) = &description {10801086         body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));10811087     }1082-    body.push_str(&format!(1083-        "<p class=\"repo-meta\">Branch <code>{}</code> @ <code>{}</code></p>\n",1084-        esc(&branch),1085-        esc(&short_sha)1086-    ));1087-    body.push_str(&format!(1088-        "<p>CI: {}</p>\n",1089-        forge::verdict_badge(forge_data.ci.latest_verdict()),1090-    ));10911088     body.push_str(&clone_block(10921089         clone_url.as_deref(),10931090         public_origin.as_deref(),10941091         &branch,10951092         &short_sha,1093+        forge_data.ci.latest_verdict(),10961094         bundle_size,10971095         file_hash(&snap.bundle).as_deref(),10981096         tarball_size,@@ -1284,18 +1282,25 @@ mod tests {12841282             "raw HTML escaped"12851283         );12861284         assert!(index.contains("&lt;script&gt;"), "escaped HTML present");1287-        // Header: branch + short sha, live open f0rge counts, CI verdict badge.1285+        // Header (m6 finish, f0rge #53): branch/sha/CI live ONLY in the1286+        // clone summary — no standalone repo-meta / CI dupe line.12881287         assert!(index.contains("main"), "branch shown");12891288         assert!(!index.contains("TODO"), "no TODO markers left");12901289         assert!(index.contains("issues (0)"), "nav open issues 0");12911290         assert!(index.contains("pulls (0)"), "nav open pulls 0");1292-        assert!(index.contains("repo-meta"), "branch/sha meta kept");1293-        assert!(index.contains("CI:"), "CI verdict shown");1294-        assert!(index.contains("badge"), "CI verdict badge");1291+        assert!(!index.contains("repo-meta"), "no dupe repo-meta line");1292+        assert!(!index.contains("<p>CI:"), "no dupe CI line");1293+        assert!(index.contains("badge"), "CI verdict badge in summary");1294+        let summ = index.find("<summary>Clone").expect("summary");1295+        let send = index[summ..].find("</summary>").expect("summary end") + summ;1296+        let summary = &index[summ..send];1297+        assert!(summary.contains("main"), "branch in summary");1298+        assert!(summary.contains("CI"), "CI in summary");1299+        assert!(summary.contains("badge"), "badge in summary");12951300         // f0rge #36: code tab carries no redundant counts line.12961301         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");1297-        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;1298-        let head = &index[head_start..head_end];1302+        let details_end = index[head_start..].find("</details>").expect("details end") + head_start;1303+        let head = &index[head_start..details_end];12991304         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {13001305             assert!(!head.contains(needle), "code tab drops {needle}");13011306         }@@ -1362,11 +1367,15 @@ mod tests {13621367         let index = std::fs::read_to_string(out.join("index.html")).expect("index");13631368         assert!(index.contains("issues (1)"), "nav open issues");13641369         assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");1365-        assert!(index.contains("repo-meta"), "branch/sha meta kept");1366-        assert!(index.contains("CI:"), "CI meta kept");1370+        assert!(1371+            !index.contains("repo-meta"),1372+            "no dupe repo-meta (f0rge #53)"1373+        );1374+        assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");1375+        assert!(index.contains("badge"), "CI badge in summary");13671376         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");1368-        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;1369-        let head = &index[head_start..head_end];1377+        let details_end = index[head_start..].find("</details>").expect("details end") + head_start;1378+        let head = &index[head_start..details_end];13701379         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {13711380             assert!(!head.contains(needle), "code tab drops {needle}");13721381         }@@ -2041,13 +2050,15 @@ mod tests {20412050             let out = mktemp(&format!("out-ci-{verdict}"));20422051             run_build(&repo, &out, &opts()).expect("build");20432052             let index = std::fs::read_to_string(out.join("index.html")).expect("index");2044-            let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");2045-            let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;2046-            let head = &index[head_start..head_end];2053+            let summ = index.find("<summary>Clone").expect("summary");2054+            let send = index[summ..].find("</summary>").expect("summary end") + summ;2055+            let summary = &index[summ..send];20472056             assert!(2048-                head.contains(&format!("badge {verdict}")),2049-                "code head carries badge {verdict}"2057+                summary.contains(&format!("badge {verdict}")),2058+                "clone summary carries badge {verdict}"20502059             );2060+            assert!(!index.contains("repo-meta"), "no dupe meta");2061+            assert!(!index.contains("<p>CI:"), "no dupe CI line");20512062         }20522063     }20532064 @@ -2249,4 +2260,54 @@ mod tests {22492260             "ignored still in raw"22502261         );22512262     }2263+2264+    #[test]2265+    fn finish_page_heads_and_clone_summary() {2266+        // m6 finish (f0rge #50 + #53): one head pattern everywhere, branch+CI2267+        // only in the clone summary, no dupe line.2268+        let (_repo, out) = build_fixture();2269+        let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);2270+        let index = read("index.html");2271+        // Code tab: repo-head with h1, summary carries branch @ sha + CI.2272+        let head_pos = index.find("<div class=\"repo-head\">").expect("repo-head");2273+        assert!(index[head_pos..].contains("<h1>"), "code h1 in repo-head");2274+        let s0 = index.find("<summary>Clone").expect("summary");2275+        let s1 = index[s0..].find("</summary>").expect("summary end") + s0;2276+        let summary = &index[s0..s1];2277+        assert!(summary.contains("Clone &amp; download"), "summary label");2278+        assert!(summary.contains("main"), "branch in summary");2279+        assert!(summary.contains("CI"), "CI in summary");2280+        assert!(summary.contains("badge"), "CI badge in summary");2281+        assert!(!index.contains("repo-meta"), "no dupe repo-meta");2282+        assert!(!index.contains("<p>CI:"), "no dupe CI line");2283+        // Branch appears in summary; outside details it is not repeated as2284+        // a meta line (description/counts carry no branch text here).2285+        let details_pos = index.find("<details class=\"clonebox").expect("details");2286+        assert!(2287+            s0 > head_pos && s0 >= details_pos,2288+            "summary inside code head"2289+        );2290+        // Every index/Form page opens with the same page-head h1 row.2291+        for (rel, h1) in [2292+            ("issues/index.html", "<h1>Issues</h1>"),2293+            ("pulls/index.html", "<h1>Pulls</h1>"),2294+            ("commits.html", "<h1>Commits</h1>"),2295+            ("tasks/index.html", "<h1>Tasks</h1>"),2296+            ("ci/index.html", "<h1>CI</h1>"),2297+            ("releases/index.html", "<h1>Releases</h1>"),2298+            ("wiki/index.html", "<h1>Wiki</h1>"),2299+            ("new-issue.html", "<h1>Suggest an issue</h1>"),2300+            ("new-pr.html", "<h1>Suggest a change</h1>"),2301+        ] {2302+            let html = read(rel);2303+            let hpos = html.find("<div class=\"page-head\">").expect(rel);2304+            let h1pos = html.find(h1).unwrap_or_else(|| panic!("{rel} missing h1"));2305+            let close = html[hpos..].find("</div>").expect("close") + hpos;2306+            assert!(h1pos > hpos && h1pos < close, "{rel}: h1 inside page-head");2307+        }2308+        // Agents guide renders its markdown h1; spacing comes from the2309+        // shared `main>h1:first-child` gap-var rule (CSS assert in layout).2310+        let agents = read("agents.html");2311+        assert!(agents.contains("<h1>"), "agents h1");2312+    }22522313 }
src/commits.rs+1 −1
diff --git a/src/commits.rs b/src/commits.rsindex e3aa5de..67872bd 100644--- a/src/commits.rs+++ b/src/commits.rs@@ -36,7 +36,7 @@ pub fn render_loaded(3636     snap_sha: &str,3737     description: Option<&str>,3838 ) -> Result<usize, String> {39-    let mut body = String::from("<h1>Commits</h1>\n");39+    let mut body = String::from("<div class=\"page-head\"><h1>Commits</h1></div>\n");4040     if log.is_empty() {4141         body.push_str("<p class=\"muted\">No commits yet.</p>\n");4242     } else {
src/forge.rs+2 −2
diff --git a/src/forge.rs b/src/forge.rsindex 9a1387e..eec7927 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -1161,7 +1161,7 @@ fn render_tasks_index(11611161     short_sha: &str,11621162     description: Option<&str>,11631163 ) -> String {1164-    let mut body = String::from("<h1>Tasks</h1>\n");1164+    let mut body = String::from("<div class=\"page-head\"><h1>Tasks</h1></div>\n");11651165     if tasks.is_empty() {11661166         body.push_str("<p class=\"muted\">No tasks yet.</p>\n");11671167     } else {@@ -1213,7 +1213,7 @@ fn render_ci_index(12131213     short_sha: &str,12141214     description: Option<&str>,12151215 ) -> String {1216-    let mut body = String::from("<h1>CI</h1>\n");1216+    let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");12171217     body.push_str(&meta_row("runner:", &esc(&ci.runner)));12181218     if ci.steps.is_empty() {12191219         body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");
src/inbox.rs+1 −1
diff --git a/src/inbox.rs b/src/inbox.rsindex ecceb1d..a6cf081 100644--- a/src/inbox.rs+++ b/src/inbox.rs@@ -619,7 +619,7 @@ fn form_page(619619     description: Option<&str>,620620 ) -> String {621621     let body = format!(622-        "<h1>{heading}</h1>\n<p>{intro}</p>\n\622+        "<div class=\"page-head\"><h1>{heading}</h1></div>\n<p>{intro}</p>\n\623623          <p>What happens: your text goes to a private inbox only the maintainer reads — \624624          never published on this site. No account needed. The server answers a one-time \625625          ack id and shows nothing back.</p>\n\
src/layout.rs+47 −1
diff --git a/src/layout.rs b/src/layout.rsindex 096a227..70482a2 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -293,7 +293,7 @@ fn page_site_inner(293293 <html lang=\"en\">\n\294294 <head>\n{head}</head>\n\295295 <body>\n\296-<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\296+<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\297297 <main class=\"wrap\">\n{body}\n</main>\n\298298 {footer}\n\299299 </body>\n\@@ -583,6 +583,52 @@ mod tests {583583         assert!(!provenance_footer(Some("main"), Some("")).contains('@'));584584     }585585 586+    #[test]587+    fn finish_gap_vars_logo_footer() {588+        // m6 finish (f0rge #50 + #51 + #52): unified head gaps via vars,589+        // 48px logo, right footer — all in one CSS + header assert.590+        let css = include_str!("../static/style.css");591+        // #50: single vars, mobile tighter (.75/1rem) via :root, desktop592+        // looser (1.25/1.5rem) via the existing min-width breakpoint.593+        assert!(css.contains("--gap-head"), "gap-head var");594+        assert!(css.contains("--gap-content"), "gap-content var");595+        assert!(css.contains("--gap-head:.75rem"), "mobile head gap");596+        assert!(css.contains("--gap-content:1rem"), "mobile content gap");597+        assert!(css.contains("--gap-head:1.25rem"), "desktop head gap");598+        assert!(css.contains("--gap-content:1.5rem"), "desktop content gap");599+        assert!(600+            css.contains(".page-head") && css.contains("margin-top:var(--gap-head)"),601+            "page-head uses gap-head"602+        );603+        assert!(604+            css.contains(".page-head") && css.contains("margin-bottom:var(--gap-content)"),605+            "page-head uses gap-content"606+        );607+        assert!(608+            css.contains(".repo-head") && css.contains("margin-top:var(--gap-head)"),609+            "repo-head uses gap-head"610+        );611+        assert!(612+            css.contains("main>h1:first-child") && css.contains("margin-top:var(--gap-head)"),613+            "bare h1 shares nav->head gap"614+        );615+        // #51: 48px logo (was 24px / 1.5rem), vector kept, header centers.616+        assert!(css.contains(".site-logo"), "logo rule");617+        assert!(css.contains("width:3rem"), "logo 48px width");618+        assert!(css.contains("height:3rem"), "logo 48px height");619+        assert!(!css.contains("width:1.5rem"), "old 24px gone");620+        let html = page("t", "<nav></nav>", "<p>b</p>");621+        assert!(622+            html.contains("width=\"48\" height=\"48\""),623+            "header img 48px attrs"624+        );625+        // #52: footer provenance sits right, both themes (pure CSS var block).626+        assert!(627+            css.contains(".footer-provenance") && css.contains("text-align:right"),628+            "footer right-aligned"629+        );630+    }631+586632     #[test]587633     fn provenance_footer_names_snapshot_and_escapes() {588634         // m6 footer-line (f0rge #41): build pages name branch @ sha;
src/releases.rs+1 −1
diff --git a/src/releases.rs b/src/releases.rsindex cceb905..03ca8e5 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -391,7 +391,7 @@ pub fn render_loaded(391391     description: Option<&str>,392392 ) -> Result<(), String> {393393     let root = repo.join(".forge").join("releases");394-    let mut body = String::from("<h1>Releases</h1>\n");394+    let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");395395     if releases.is_empty() {396396         body.push_str("<p class=\"muted\">No releases yet.</p>\n");397397     } else {
src/wiki.rs+1 −1
diff --git a/src/wiki.rs b/src/wiki.rsindex 746c229..8e332d5 100644--- a/src/wiki.rs+++ b/src/wiki.rs@@ -275,7 +275,7 @@ pub fn render(275275     let wiki_dir = out.join("wiki");276276     std::fs::create_dir_all(&wiki_dir).map_err(|e| format!("hu13: create wiki dir: {e}"))?;277277 278-    let mut body = String::from("<h1>Wiki</h1>\n");278+    let mut body = String::from("<div class=\"page-head\"><h1>Wiki</h1></div>\n");279279     if pages.is_empty() {280280         body.push_str("<p class=\"muted\">No wiki pages yet.</p>\n");281281     } else {
static/style.css+9 −6
diff --git a/static/style.css b/static/style.cssindex a305df3..a75e481 100644--- a/static/style.css+++ b/static/style.css@@ -16,6 +16,8 @@1616   --border:#292e42;1717   --visited:#9aa5ce;1818   --btn-fg:#16161e;19+  --gap-head:.75rem;20+  --gap-content:1rem;1921   --diff-add:#9ece6a;2022   --diff-add-bg:#233328;2123   --diff-del:#f7768e;@@ -68,7 +70,7 @@ main{padding:1.25rem 0 3rem;min-width:0}6870    CSS margin-top (never <br> spacers); the base stays tight for 360px6971    and wider screens get a touch more. Title is explicit 2rem so it7072    reads as the page title on every viewport. */71-.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}73+.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:var(--gap-head);margin-bottom:var(--gap-content)}7274 .repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}7375 .clone,.repo-meta{margin:.25rem 0;min-width:0}7476 .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}@@ -123,7 +125,7 @@ footer.site-footer{border-top:1px solid var(--border)}123125 footer.site-footer .wrap{padding:1rem}124126 /* Provenance line (m6/footer-line, f0rge #41): the single muted line125127    inside the existing footer block — small, no extra whitespace. */126-footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}128+footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem;text-align:right}127129 /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.128130    Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/129131    .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav@@ -132,7 +134,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}132134 .site-header .wrap.header-bar{display:flex;flex-wrap:nowrap;align-items:center;gap:.5rem .75rem}133135 .brand-lockup{display:inline-flex;align-items:center;gap:.5rem;flex:none;color:var(--fg);text-decoration:none;min-height:40px}134136 .brand-lockup:visited{color:var(--fg)}135-.site-logo{display:block;width:1.5rem;height:1.5rem;flex:none}137+.site-logo{display:block;width:3rem;height:3rem;flex:none}136138 .hu13-word{font-weight:700;color:var(--blue)}137139 .sep{flex:none;color:var(--muted)}138140 .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}@@ -144,7 +146,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}144146 @media (min-width:768px){145147   .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}146148   .site-nav .wrap{gap:.25rem 1.25rem}147-  .repo-head{margin-top:1.25rem}149+  :root{--gap-head:1.25rem;--gap-content:1.5rem}148150   main{padding:1.5rem 0 3rem}149151 }150152 /* Code tab two columns (m6/code-cols, f0rge #45): files + README share@@ -160,7 +162,7 @@ footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}160162   .cols-code{grid-template-columns:minmax(0,7fr) minmax(0,5fr)}161163 }162164 /* Code conventions (m6, f0rge #20): breadcrumbs, file meta, line anchors, clone block. */163-.crumbs{margin:.5rem 0;font-size:.95rem}165+.crumbs{margin:var(--gap-head) 0 .5rem;font-size:.95rem}164166 .crumbs a{color:var(--cyan);text-decoration:none}165167 .crumbs a:hover,.crumbs a:focus{text-decoration:underline}166168 .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)}@@ -195,8 +197,9 @@ a.ln:hover,a.ln:focus{text-decoration:underline}195197 .table-scroll td.trunc-title,.trunc-title{max-width:60ch}196198 /* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +197199    button share one flex row; wrap allowed on 360px but stays adjacent. */198-.page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}200+.page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem;margin-top:var(--gap-head);margin-bottom:var(--gap-content)}199201 .page-head h1{margin:0}202+main>h1:first-child{margin-top:var(--gap-head);margin-bottom:var(--gap-content)}200203 .page-head .btn-new{flex:none}201204 202205 /* Code-gitea (m6, f0rge #33 + #34): file TABLE, language bar, clone details. */
tests/code_space.rs+36 −21
diff --git a/tests/code_space.rs b/tests/code_space.rsindex 7d73a3a..9e01e8c 100644--- a/tests/code_space.rs+++ b/tests/code_space.rs@@ -93,22 +93,26 @@ fn parse_rem(value: &str) -> Option<f32> {9393 #[test]9494 fn repo_head_spacing_is_css_margin_not_br_tags() {9595     // Structure assert: the code tab keeps its stacked repo-head96-    // (name + meta + clone block) with air from CSS margin — no `<br>`97-    // spacers between the navbar and the repo-head.96+    // (name + clone summary with branch/CI) with air from CSS margin —97+    // no `<br>` spacers between the navbar and the repo-head.98+    // (m6 finish, f0rge #53: branch/sha/CI live ONLY in the clone summary.)9899     let out = build_fixture();99100     let index = std::fs::read_to_string(out.join("index.html")).expect("index");100101     let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");101-    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;102-    let head = &index[head_start..head_end];102+    let details_end = index[head_start..].find("</details>").expect("details end") + head_start;103+    let head = &index[head_start..details_end];103104     assert!(head.contains("<h1>"), "repo-head keeps its title");104105     assert!(105106         !head.contains("<br"),106107         "spacing is CSS margin, not <br> tags"107108     );108-    assert!(109-        head.contains("repo-meta"),110-        "branch/sha meta line kept inside repo-head"111-    );109+    assert!(!index.contains("repo-meta"), "no dupe repo-meta line");110+    assert!(!index.contains("<p>CI:"), "no dupe CI line");111+    let s0 = index.find("<summary>Clone").expect("summary");112+    let s1 = index[s0..].find("</summary>").expect("summary end") + s0;113+    let summary = &index[s0..s1];114+    assert!(summary.contains("main"), "branch in summary");115+    assert!(summary.contains("CI"), "CI in summary");112116     // The header bar ends (</header>) before <main> carries the repo-head:113117     // the break between nav and repo-head is a block boundary + margin.114118     let header_end = index.find("</header>").expect("header close");@@ -122,13 +126,18 @@ fn repo_head_spacing_is_css_margin_not_br_tags() {122126         "no spacer tags between navbar and repo-head"123127     );124128 125-    // CSS assert: `.repo-head` carries the top margin.129+    // CSS assert: `.repo-head` carries the top margin via --gap-head.126130     let css = css();127131     let base = css.split("@media (min-width").next().expect("base css");128132     let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");129133     assert!(130-        parse_rem(&margin).is_some_and(|v| v > 0.0),131-        "repo-head margin-top is positive breathing room, got {margin:?}"134+        margin.contains("var(--gap-head)"),135+        "repo-head margin-top uses --gap-head, got {margin:?}"136+    );137+    let gap = rule_value(base, ":root{", "--gap-head").expect("gap-head var");138+    assert!(139+        parse_rem(&gap).is_some_and(|v| v > 0.0),140+        "gap-head is positive breathing room, got {gap:?}"132141     );133142 }134143 @@ -154,15 +163,17 @@ fn repo_head_title_reads_as_page_title() {154163 #[test]155164 fn mobile_stays_tight_wider_screens_get_more() {156165     // 360px must not drown in whitespace: the base (pre-media-query)157-    // margin stays modest; >=768px may open up a touch more.166+    // --gap-head stays modest; >=768px may open up a touch more.167+    // (m6 finish, f0rge #50: gaps ride on --gap-head/--gap-content vars.)158168     let css = css();169+    assert!(css.contains("--gap-head"), "gap-head var");170+    assert!(css.contains("--gap-content"), "gap-content var");159171     let base = css.split("@media (min-width").next().expect("base css");160-    let base_margin =161-        rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");162-    let base_rem = parse_rem(&base_margin).expect("base margin in rem");172+    let base_gap = rule_value(base, ":root{", "--gap-head").expect("base gap-head");173+    let base_rem = parse_rem(&base_gap).expect("base gap in rem");163174     assert!(164175         base_rem <= 1.0,165-        "mobile margin-top stays tight (<=1rem), got {base_margin:?}"176+        "mobile gap-head stays tight (<=1rem), got {base_gap:?}"166177     );167178     let desktop = css168179         .split("@media (min-width:768px)")@@ -170,15 +181,19 @@ fn mobile_stays_tight_wider_screens_get_more() {170181         .expect("768px breakpoint");171182     // Only the current breakpoint block: cut before the next @media.172183     let block = desktop.split("@media").next().expect("768 block");173-    let wide_margin =174-        rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");175-    let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");184+    let wide_gap = rule_value(block, ":root{", "--gap-head").expect("wide gap-head");185+    let wide_rem = parse_rem(&wide_gap).expect("wide gap in rem");176186     assert!(177187         wide_rem >= base_rem,178-        "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"188+        "wider screens keep at least base air ({wide_gap:?} vs {base_gap:?})"179189     );180190     assert!(181191         wide_rem <= 2.0,182-        "even wide stays modest (<=2rem), got {wide_margin:?}"192+        "even wide stays modest (<=2rem), got {wide_gap:?}"193+    );194+    // Heads share the var (single pattern, not per-page px).195+    assert!(196+        base.contains("margin-top:var(--gap-head)"),197+        "heads use gap-head var"183198     );184199 }
tests/forge_readers.rs+12 −3
diff --git a/tests/forge_readers.rs b/tests/forge_readers.rsindex b43bfcf..27762ef 100644--- a/tests/forge_readers.rs+++ b/tests/forge_readers.rs@@ -316,10 +316,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {316316     let index = read("index.html");317317     assert!(index.contains("issues (1)"), "nav open issue count");318318     assert!(index.contains("pulls (1)"), "nav open pull count");319-    assert!(index.contains("repo-meta"), "branch/sha meta kept");319+    assert!(320+        !index.contains("repo-meta"),321+        "no dupe repo-meta (f0rge #53)"322+    );323+    assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");324+    let summ = index.find("<summary>Clone").expect("summary");325+    let send = index[summ..].find("</summary>").expect("summary end") + summ;326+    let summary = &index[summ..send];327+    assert!(summary.contains("CI"), "CI in summary");328+    assert!(summary.contains("badge fail"), "fail badge in summary");320329     let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");321-    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;322-    let head = &index[head_start..head_end];330+    let details_end = index[head_start..].find("</details>").expect("details end") + head_start;331+    let head = &index[head_start..details_end];323332     for needle in ["issues (", "pulls (", "tasks (", "releases ("] {324333         assert!(!head.contains(needle), "code tab drops {needle}");325334     }
tests/responsive.rs+5 −1
diff --git a/tests/responsive.rs b/tests/responsive.rsindex 85b664e..9879f70 100644--- a/tests/responsive.rs+++ b/tests/responsive.rs@@ -233,9 +233,13 @@ fn touch_targets_and_stacking_header() {233233     }234234     let out = build_fixture();235235     let index = std::fs::read_to_string(out.join("index.html")).expect("index");236-    for needle in ["repo-head", "cols", "repo-meta"] {236+    for needle in ["repo-head", "cols", "clonebox"] {237237         assert!(index.contains(needle), "index.html has {needle}");238238     }239+    assert!(240+        !index.contains("repo-meta"),241+        "no dupe repo-meta (f0rge #53)"242+    );239243 }240244 241245 #[test]