.forge/prs/765567ca-0017-40cc-ae2b-69dfb741f837/patch.diff 28.6 KiB 589 lines Raw
1 From 89d1730312f68be76ac1c34295dc867c3ba0a33f 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