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 & download <span class=\"muted\">{} @ {}</span></summary>\n<div class=\"clone-body\">\n",52 + <summary>Clone & 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("<script>"), "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 & 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