diff --git a/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/patch.diff b/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/patch.diffnew file mode 100644index 0000000..593fb6c--- /dev/null+++ b/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/patch.diff@@ -0,0 +1,1394 @@1+From 1988123bfce44a3ec6f37316539e3d2e1688a22b Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 08:54:47 +01004+Subject: [PATCH] m6/finish-line: repo-head spacing + footer provenance5+6+---7+ src/agents.rs | 6 +-8+ src/build.rs | 53 ++++++++++---9+ src/commits.rs | 25 +++++-10+ src/forge.rs | 103 +++++++++++++++++++------11+ src/inbox.rs | 18 ++++-12+ src/layout.rs | 122 +++++++++++++++++++++++++++++13+ src/releases.rs | 41 ++++++++--14+ src/wiki.rs | 18 +++--15+ static/style.css | 15 +++-16+ tests/code_space.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++17+ tests/provenance.rs | 161 ++++++++++++++++++++++++++++++++++++++18+ 11 files changed, 687 insertions(+), 59 deletions(-)19+ create mode 100644 tests/code_space.rs20+ create mode 100644 tests/provenance.rs21+22+diff --git a/src/agents.rs b/src/agents.rs23+index f0f3646..d4e49f1 10064424+--- a/src/agents.rs25++++ b/src/agents.rs26+@@ -15,7 +15,7 @@27+ use std::path::Path;28+ 29+ use crate::build::markdown_to_html;30+-use crate::layout::{esc, page_site};31++use crate::layout::{esc, page_site_with_provenance};32+ 33+ /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).34+ /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,35+@@ -114,12 +114,14 @@ pub fn render(36+ // source so the raw `agents.md` and rendered `agents.html` agree. The37+ // markdown renderer escapes raw HTML again, so `&` etc. stay safe.38+ let html_body = markdown_to_html(&md);39+- let html = page_site(40++ let html = page_site_with_provenance(41+ &format!("agents — {site_name}"),42+ nav,43+ &html_body,44+ site_name,45+ description,46++ branch,47++ short_sha,48+ );49+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;50+ std::fs::write(out.join("agents.html"), html)51+diff --git a/src/build.rs b/src/build.rs52+index 96c819f..5d176ba 10064453+--- a/src/build.rs54++++ b/src/build.rs55+@@ -941,8 +941,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu56+ Some((&parent_href, &parent_label)),57+ &history,58+ ));59+- let html =60+- crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);61++ let html = crate::layout::page_site_with_provenance(62++ &format!("{dir} — {name}"),63++ &nav,64++ &body,65++ &name,66++ desc_opt,67++ &branch,68++ &short_sha,69++ );70+ let dest = out.join("files").join(format!("{dir}.html"));71+ if let Some(parent) = dest.parent() {72+ std::fs::create_dir_all(parent)73+@@ -988,7 +995,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu74+ ));75+ body.push_str(&render_text_pre(&display));76+ }77+- let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);78++ let html = crate::layout::page_site_with_provenance(79++ &format!("{f} — {name}"),80++ &nav,81++ &body,82++ &name,83++ desc_opt,84++ &branch,85++ &short_sha,86++ );87+ let dest = out.join("files").join(format!("{f}.html"));88+ if let Some(parent) = dest.parent() {89+ std::fs::create_dir_all(parent)90+@@ -1000,15 +1015,33 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu91+ // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):92+ // one struct pass — the same loaded vecs feed both HTML and JSON.93+ let commit_log = commits::load(repo, &branch, opts.max_log)?;94+- commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;95++ commits::render_loaded(96++ repo,97++ out,98++ &commit_log,99++ &name,100++ &nav,101++ &branch,102++ &short_sha,103++ desc_opt,104++ )?;105+ feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;106+- let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;107++ let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;108+ // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.109+ // Tasks/releases/wiki indexes always render even when empty (m6110+ // hide-empty, f0rge #38): only the nav hides the tab, deep links111+ // keep working with their "No X yet" empty states.112+- forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;113+- releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;114++ forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;115++ releases::render_loaded(116++ &release_data,117++ repo,118++ out,119++ &name,120++ &nav,121++ &branch,122++ &short_sha,123++ desc_opt,124++ )?;125+ api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;126+ agents::render(127+ out,128+@@ -1020,7 +1053,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu129+ desc_opt,130+ )?;131+ // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.132+- crate::inbox::render_forms(out, &name, &nav, desc_opt)?;133++ crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;134+ 135+ // Snapshot last so the index can link sizes.136+ let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;137+@@ -1093,7 +1126,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu138+ }139+ body.push_str("</section></div>\n");140+ 141+- let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);142++ let index = crate::layout::page_site_with_provenance(143++ &name, &nav, &body, &name, desc_opt, &branch, &short_sha,144++ );145+ std::fs::write(out.join("index.html"), index)146+ .map_err(|e| format!("hu13: write index.html: {e}"))?;147+ 148+diff --git a/src/commits.rs b/src/commits.rs149+index 7075113..60566f2 100644150+--- a/src/commits.rs151++++ b/src/commits.rs152+@@ -9,7 +9,7 @@153+ use std::path::Path;154+ 155+ use crate::gitx;156+-use crate::layout::{diff_html, esc, page_site};157++use crate::layout::{diff_html, esc, page_site_with_provenance};158+ 159+ /// Short display form of a full sha (first 7 hex chars, git-style).160+ #[must_use]161+@@ -25,12 +25,15 @@ pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commi162+ 163+ /// Render `commits.html` plus one `commit/<sha>.html` per commit from an164+ /// already-loaded log (see [`load`]).165++#[allow(clippy::too_many_arguments)]166+ pub fn render_loaded(167+ repo: &Path,168+ out: &Path,169+ log: &[gitx::Commit],170+ site_name: &str,171+ nav: &str,172++ branch: &str,173++ snap_sha: &str,174+ description: Option<&str>,175+ ) -> Result<usize, String> {176+ let mut body = String::from("<h1>Commits</h1>\n");177+@@ -50,12 +53,14 @@ pub fn render_loaded(178+ }179+ body.push_str("</tbody>\n</table></div>\n");180+ }181+- let html = page_site(182++ let html = page_site_with_provenance(183+ &format!("commits — {site_name}"),184+ nav,185+ &body,186+ site_name,187+ description,188++ branch,189++ snap_sha,190+ );191+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;192+ std::fs::write(out.join("commits.html"), html)193+@@ -75,12 +80,14 @@ pub fn render_loaded(194+ esc(&message),195+ diff_html(&diff),196+ );197+- let html = page_site(198++ let html = page_site_with_provenance(199+ &format!("commit {} — {site_name}", short_sha(&c.sha)),200+ nav,201+ &detail,202+ site_name,203+ description,204++ branch,205++ snap_sha,206+ );207+ std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)208+ .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;209+@@ -103,7 +110,17 @@ pub fn render(210+ description: Option<&str>,211+ ) -> Result<usize, String> {212+ let log = load(repo, branch, max_log)?;213+- render_loaded(repo, out, &log, site_name, nav, description)214++ let snap_sha = gitx::rev_parse_short(repo, branch).unwrap_or_default();215++ render_loaded(216++ repo,217++ out,218++ &log,219++ site_name,220++ nav,221++ branch,222++ &snap_sha,223++ description,224++ )225+ }226+ 227+ #[cfg(test)]228+diff --git a/src/forge.rs b/src/forge.rs229+index b41ed7a..040b552 100644230+--- a/src/forge.rs231++++ b/src/forge.rs232+@@ -23,7 +23,7 @@ use std::path::Path;233+ use serde::Deserialize;234+ 235+ use crate::build::markdown_to_html;236+-use crate::layout::{diff_html, esc, page_site};237++use crate::layout::{diff_html, esc, page_site_with_provenance};238+ 239+ /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).240+ pub const FORGE_MAX_BYTES: u64 = 256 * 1024;241+@@ -830,6 +830,8 @@ fn render_issue_page(242+ issue: &Issue,243+ site_name: &str,244+ nav: &str,245++ branch: &str,246++ short_sha: &str,247+ description: Option<&str>,248+ ) -> String {249+ let mut body = format!(250+@@ -879,16 +881,25 @@ fn render_issue_page(251+ body.push_str("</ul>\n");252+ }253+ body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");254+- page_site(255++ page_site_with_provenance(256+ &format!("#{} {} — {site_name}", issue.number, issue.title),257+ nav,258+ &body,259+ site_name,260+ description,261++ branch,262++ short_sha,263+ )264+ }265+ 266+-fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {267++fn render_pr_page(268++ pr: &Pr,269++ site_name: &str,270++ nav: &str,271++ branch: &str,272++ short_sha: &str,273++ description: Option<&str>,274++) -> String {275+ let mut body = format!(276+ "<h1>#{n} {title}</h1>\n",277+ n = pr.number,278+@@ -963,12 +974,14 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>279+ body.push_str("</ul>\n");280+ }281+ body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");282+- page_site(283++ page_site_with_provenance(284+ &format!("#{} {} — {site_name}", pr.number, pr.title),285+ nav,286+ &body,287+ site_name,288+ description,289++ branch,290++ short_sha,291+ )292+ }293+ 294+@@ -976,6 +989,8 @@ fn render_issues_index(295+ data: &ForgeData,296+ site_name: &str,297+ nav: &str,298++ branch: &str,299++ short_sha: &str,300+ description: Option<&str>,301+ ) -> Result<String, String> {302+ let mut body =303+@@ -1019,12 +1034,14 @@ fn render_issues_index(304+ }305+ body.push_str("</ul>\n");306+ }307+- Ok(page_site(308++ Ok(page_site_with_provenance(309+ &format!("issues — {site_name}"),310+ nav,311+ &body,312+ site_name,313+ description,314++ branch,315++ short_sha,316+ ))317+ }318+ 319+@@ -1032,6 +1049,8 @@ fn render_pulls_index(320+ data: &ForgeData,321+ site_name: &str,322+ nav: &str,323++ branch: &str,324++ short_sha: &str,325+ description: Option<&str>,326+ ) -> String {327+ let prs = &data.prs;328+@@ -1059,12 +1078,14 @@ fn render_pulls_index(329+ "<p class=\"muted\">{n} closed</p>\n",330+ n = data.closed_pr_count(),331+ ));332+- page_site(333++ page_site_with_provenance(334+ &format!("pulls — {site_name}"),335+ nav,336+ &body,337+ site_name,338+ description,339++ branch,340++ short_sha,341+ )342+ }343+ 344+@@ -1072,6 +1093,8 @@ fn render_tasks_index(345+ tasks: &[TaskItem],346+ site_name: &str,347+ nav: &str,348++ branch: &str,349++ short_sha: &str,350+ description: Option<&str>,351+ ) -> String {352+ let mut body = String::from("<h1>Tasks</h1>\n");353+@@ -1107,16 +1130,25 @@ fn render_tasks_index(354+ }355+ }356+ }357+- page_site(358++ page_site_with_provenance(359+ &format!("tasks — {site_name}"),360+ nav,361+ &body,362+ site_name,363+ description,364++ branch,365++ short_sha,366+ )367+ }368+ 369+-fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {370++fn render_ci_index(371++ ci: &CiInfo,372++ site_name: &str,373++ nav: &str,374++ branch: &str,375++ short_sha: &str,376++ description: Option<&str>,377++) -> String {378+ let mut body = String::from("<h1>CI</h1>\n");379+ body.push_str(&meta_row("runner:", &esc(&ci.runner)));380+ if ci.steps.is_empty() {381+@@ -1170,12 +1202,14 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<382+ }383+ None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),384+ }385+- page_site(386++ page_site_with_provenance(387+ &format!("ci — {site_name}"),388+ nav,389+ &body,390+ site_name,391+ description,392++ branch,393++ short_sha,394+ )395+ }396+ 397+@@ -1193,43 +1227,45 @@ pub fn render_loaded(398+ out: &Path,399+ site_name: &str,400+ nav: &str,401++ branch: &str,402++ short_sha: &str,403+ description: Option<&str>,404+ ) -> Result<(), String> {405+ write_page(406+ out,407+ "issues/index.html",408+- &render_issues_index(data, site_name, nav, description)?,409++ &render_issues_index(data, site_name, nav, branch, short_sha, description)?,410+ )?;411+ for issue in &data.issues {412+ write_page(413+ out,414+ &format!("issues/{}.html", issue.id),415+- &render_issue_page(issue, site_name, nav, description),416++ &render_issue_page(issue, site_name, nav, branch, short_sha, description),417+ )?;418+ }419+ 420+ write_page(421+ out,422+ "pulls/index.html",423+- &render_pulls_index(data, site_name, nav, description),424++ &render_pulls_index(data, site_name, nav, branch, short_sha, description),425+ )?;426+ for pr in &data.prs {427+ write_page(428+ out,429+ &format!("pulls/{}.html", pr.id),430+- &render_pr_page(pr, site_name, nav, description),431++ &render_pr_page(pr, site_name, nav, branch, short_sha, description),432+ )?;433+ }434+ 435+ write_page(436+ out,437+ "tasks/index.html",438+- &render_tasks_index(&data.tasks, site_name, nav, description),439++ &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),440+ )?;441+ write_page(442+ out,443+ "ci/index.html",444+- &render_ci_index(&data.ci, site_name, nav, description),445++ &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),446+ )?;447+ 448+ Ok(())449+@@ -1360,7 +1396,8 @@ mod tests {450+ assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));451+ 452+ let out = mktemp("pr-out");453+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");454++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)455++ .expect("render");456+ let html =457+ std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))458+ .expect("pr page");459+@@ -1389,7 +1426,8 @@ mod tests {460+ )461+ .unwrap();462+ let out = mktemp("pr-diff-out");463+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");464++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)465++ .expect("render");466+ let html =467+ std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))468+ .expect("pr page");469+@@ -1450,7 +1488,8 @@ mod tests {470+ assert_eq!(data.ci.latest_verdict(), "fail");471+ 472+ let out = mktemp("taskci-out");473+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");474++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)475++ .expect("render");476+ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");477+ for status in ["todo", "doing", "blocked", "done"] {478+ assert!(tasks.contains(status), "group {status}");479+@@ -1471,7 +1510,8 @@ mod tests {480+ assert!(data.tasks.is_empty());481+ assert_eq!(data.ci.latest_verdict(), "none");482+ let out = mktemp("empty-out");483+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");484++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)485++ .expect("render");486+ for page in [487+ "issues/index.html",488+ "pulls/index.html",489+@@ -1490,7 +1530,8 @@ mod tests {490+ // Empty forge: buttons + linked empty states.491+ let repo = mktemp("submit-empty");492+ let out = mktemp("submit-empty-out");493+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");494++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)495++ .expect("render");496+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");497+ let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");498+ assert!(499+@@ -1523,7 +1564,16 @@ mod tests {500+ "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",501+ );502+ let out2 = mktemp("submit-full-out");503+- render_loaded(&load(&repo), &out2, "test", "<nav>", None).expect("render");504++ render_loaded(505++ &load(&repo),506++ &out2,507++ "test",508++ "<nav>",509++ "main",510++ "abc1234",511++ None,512++ )513++ .expect("render");514+ let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");515+ let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");516+ assert!(517+@@ -1641,7 +1691,8 @@ mod tests {518+ assert!(css.contains("align-items:center"), "inline row");519+ let repo = mktemp("page-head");520+ let out = mktemp("page-head-out");521+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");522++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)523++ .expect("render");524+ for (page, title, btn) in [525+ ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),526+ ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),527+@@ -1683,7 +1734,8 @@ mod tests {528+ ),529+ );530+ let out = mktemp("long-title-out");531+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");532++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)533++ .expect("render");534+ for page in ["issues/index.html", "pulls/index.html"] {535+ let html = std::fs::read_to_string(out.join(page)).expect(page);536+ assert!(537+@@ -1741,7 +1793,7 @@ mod tests {538+ assert_eq!(data.closed_pr_count(), 1, "merged");539+ 540+ let out = mktemp("open-counts-out");541+- render_loaded(&data, &out, "test", "<nav>", None).expect("render");542++ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");543+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");544+ assert!(issues.contains("2 closed"), "issues closed total");545+ assert!(issues.contains("Issue open"), "open row kept");546+@@ -1806,7 +1858,8 @@ mod tests {547+ "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_test.sh\n exit: 0\n duration_ms: 7\n---\nok\n",548+ );549+ let out = mktemp("cards-out");550+- render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");551++ render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)552++ .expect("render");553+ for (page, labels) in [554+ (555+ "issues/index.html",556+diff --git a/src/inbox.rs b/src/inbox.rs557+index 9806475..ecceb1d 100644558+--- a/src/inbox.rs559++++ b/src/inbox.rs560+@@ -17,7 +17,7 @@561+ 562+ use std::path::Path;563+ 564+-use crate::layout::{esc, page, page_site};565++use crate::layout::{esc, page, page_site_with_provenance};566+ 567+ /// Max chars for `title` (both kinds).568+ pub const MAX_TITLE_CHARS: usize = 300;569+@@ -614,6 +614,8 @@ fn form_page(570+ action: &str,571+ body_name: &str,572+ body_label: &str,573++ branch: &str,574++ short_sha: &str,575+ description: Option<&str>,576+ ) -> String {577+ let body = format!(578+@@ -649,12 +651,14 @@ fn form_page(579+ body_label = esc(body_label),580+ license = license_block(),581+ );582+- page_site(583++ page_site_with_provenance(584+ &format!("{heading} — {site_name}"),585+ nav,586+ &body,587+ site_name,588+ description,589++ branch,590++ short_sha,591+ )592+ }593+ 594+@@ -663,6 +667,8 @@ pub fn render_forms(595+ out: &Path,596+ site_name: &str,597+ nav: &str,598++ branch: &str,599++ short_sha: &str,600+ description: Option<&str>,601+ ) -> Result<(), String> {602+ let issue = form_page(603+@@ -673,6 +679,8 @@ pub fn render_forms(604+ InboxType::Issue.route(),605+ "body",606+ "Body",607++ branch,608++ short_sha,609+ description,610+ );611+ let pr = form_page(612+@@ -683,6 +691,8 @@ pub fn render_forms(613+ InboxType::PrPrompt.route(),614+ "prompt",615+ "Prompt",616++ branch,617++ short_sha,618+ description,619+ );620+ std::fs::write(out.join("new-issue.html"), issue)621+@@ -1036,7 +1046,7 @@ mod tests {622+ .unwrap_or(0)623+ ));624+ std::fs::create_dir_all(&dir).unwrap();625+- render_forms(&dir, "test", "", None).unwrap();626++ render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();627+ let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();628+ let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();629+ assert!(issue.contains("action=\"/inbox/issue\""));630+@@ -1071,7 +1081,7 @@ mod tests {631+ .unwrap_or(0)632+ ));633+ std::fs::create_dir_all(&dir).unwrap();634+- render_forms(&dir, "test", "", None).unwrap();635++ render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();636+ let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();637+ let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();638+ for html in [&issue, &pr] {639+diff --git a/src/layout.rs b/src/layout.rs640+index 430c397..096a227 100644641+--- a/src/layout.rs642++++ b/src/layout.rs643+@@ -186,6 +186,27 @@ pub fn apply_active(nav: &str, href: &str) -> String {644+ nav.to_string()645+ }646+ 647++/// Provenance footer line (m6 finish-line, f0rge #41): a single muted648++/// line carried on EVERY page — `generated from a snapshot of a repo649++/// and its f0rge`, plus `branch @ short_sha` when the caller knows the650++/// snapshot (build pages do; serve-time stubs don't). Extends the651++/// existing `footer.site-footer` block: exactly one per page, never652++/// duplicated. `branch`/`short_sha` go through [`esc`] (untrusted653++/// config/branch names render as text).654++#[must_use]655++pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> String {656++ let mut line = String::from("generated from a snapshot of a repo and its f0rge");657++ if let (Some(b), Some(s)) = (branch, short_sha) {658++ // Empty snapshot ids degrade to the generic line (never `main @ `).659++ if !b.is_empty() && !s.is_empty() {660++ line.push_str(&format!(" · {} @ {}", esc(b), esc(s)));661++ }662++ }663++ format!(664++ "<footer class=\"site-footer\"><div class=\"wrap\"><p class=\"muted footer-provenance\">{line}</p></div></footer>"665++ )666++}667++668+ #[must_use]669+ pub fn page_site(670+ title: &str,671+@@ -193,6 +214,45 @@ pub fn page_site(672+ body: &str,673+ site_name: &str,674+ description: Option<&str>,675++) -> String {676++ page_site_inner(title, nav, body, site_name, description, None, None)677++}678++679++/// [`page_site`] plus snapshot provenance in the footer (m6 finish-line,680++/// f0rge #41): `branch`/`short_sha` name the built snapshot so every681++/// build page carries `branch @ sha` after the generic line. Callers682++/// without a repo snapshot (serve 404s, inbox acks) use [`page_site`]683++/// and get the generic line alone.684++#[must_use]685++pub fn page_site_with_provenance(686++ title: &str,687++ nav: &str,688++ body: &str,689++ site_name: &str,690++ description: Option<&str>,691++ branch: &str,692++ short_sha: &str,693++) -> String {694++ page_site_inner(695++ title,696++ nav,697++ body,698++ site_name,699++ description,700++ Some(branch),701++ Some(short_sha),702++ )703++}704++705++#[must_use]706++fn page_site_inner(707++ title: &str,708++ nav: &str,709++ body: &str,710++ site_name: &str,711++ description: Option<&str>,712++ branch: Option<&str>,713++ short_sha: Option<&str>,714+ ) -> String {715+ let title_e = esc(title);716+ // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the717+@@ -227,6 +287,7 @@ pub fn page_site(718+ Some(href) => apply_active(nav, href),719+ None => nav.to_string(),720+ };721++ let footer = provenance_footer(branch, short_sha);722+ format!(723+ "<!DOCTYPE html>\n\724+ <html lang=\"en\">\n\725+@@ -234,6 +295,7 @@ pub fn page_site(726+ <body>\n\727+ <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\728+ <main class=\"wrap\">\n{body}\n</main>\n\729++{footer}\n\730+ </body>\n\731+ </html>\n"732+ )733+@@ -484,4 +546,64 @@ mod tests {734+ assert!(html.contains("Binary files"), "binary note intact");735+ assert!(!html.contains("<span>\n[hu13"), "markers have no span");736+ }737++738++ #[test]739++ fn every_page_carries_a_single_muted_provenance_footer() {740++ // m6 footer-line (f0rge #41): the existing footer block carries741++ // one muted provenance line; serve stubs without a snapshot742++ // get the generic line alone.743++ use super::{page_site_with_provenance, provenance_footer};744++ for html in [745++ page("t", "<a href=\"/\">home</a>", "<p>b</p>"),746++ page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234"),747++ ] {748++ assert_eq!(html.matches("<footer").count(), 1, "exactly one footer");749++ assert!(750++ html.contains("<footer class=\"site-footer\">"),751++ "existing footer block extended, not duplicated"752++ );753++ assert!(754++ html.contains("generated from a snapshot of a repo and its f0rge"),755++ "provenance line present"756++ );757++ assert!(758++ html.contains("class=\"muted footer-provenance\""),759++ "muted styling via .muted"760++ );761++ let f0 = html.find("<footer").expect("footer open");762++ let f1 = html.find("</footer>").expect("footer close");763++ assert!(!html[f0..f1].contains("<br"), "single line, no spacers");764++ }765++ // Generic footer names no snapshot.766++ assert!(767++ !provenance_footer(None, None).contains('@'),768++ "no sha when unknown"769++ );770++ // Empty ids degrade to the generic line, never `main @ `.771++ assert!(!provenance_footer(Some("main"), Some("")).contains('@'));772++ }773++774++ #[test]775++ fn provenance_footer_names_snapshot_and_escapes() {776++ // m6 footer-line (f0rge #41): build pages name branch @ sha;777++ // hostile branch names render as text.778++ use super::page_site_with_provenance;779++ let html =780++ page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234");781++ assert!(html.contains("main @ abc1234"), "branch@sha named: {html}");782++ let evil = page_site_with_provenance(783++ "t",784++ "<nav></nav>",785++ "<p>b</p>",786++ "s",787++ None,788++ "<script>alert(1)</script>",789++ "abc1234",790++ );791++ assert!(!evil.contains("<script>"), "branch escaped: {evil}");792++ assert!(793++ evil.contains("<script>alert(1)</script> @ abc1234"),794++ "escaped branch kept with sha"795++ );796++ }797+ }798+diff --git a/src/releases.rs b/src/releases.rs799+index 87493bb..507c24e 100644800+--- a/src/releases.rs801++++ b/src/releases.rs802+@@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};803+ use serde::Deserialize;804+ 805+ use crate::build::markdown_to_html;806+-use crate::layout::{esc, page_site};807++use crate::layout::{esc, page_site_with_provenance};808+ 809+ /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).810+ pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;811+@@ -322,6 +322,8 @@ fn render_release_page(812+ rel: &Release,813+ site_name: &str,814+ nav: &str,815++ branch: &str,816++ short_sha: &str,817+ description: Option<&str>,818+ ) -> String {819+ let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));820+@@ -364,23 +366,28 @@ fn render_release_page(821+ }822+ }823+ body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");824+- page_site(825++ page_site_with_provenance(826+ &format!("{} — releases — {site_name}", rel.display_title()),827+ nav,828+ &body,829+ site_name,830+ description,831++ branch,832++ short_sha,833+ )834+ }835+ 836+ /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded837+ /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.838++#[allow(clippy::too_many_arguments)]839+ pub fn render_loaded(840+ releases: &[Release],841+ repo: &Path,842+ out: &Path,843+ site_name: &str,844+ nav: &str,845++ branch: &str,846++ short_sha: &str,847+ description: Option<&str>,848+ ) -> Result<(), String> {849+ let root = repo.join(".forge").join("releases");850+@@ -401,12 +408,14 @@ pub fn render_loaded(851+ }852+ body.push_str("</tbody>\n</table></div>\n");853+ }854+- let index = page_site(855++ let index = page_site_with_provenance(856+ &format!("releases — {site_name}"),857+ nav,858+ &body,859+ site_name,860+ description,861++ branch,862++ short_sha,863+ );864+ let dest = out.join("releases").join("index.html");865+ if let Some(parent) = dest.parent() {866+@@ -423,7 +432,7 @@ pub fn render_loaded(867+ copy_tree(&src_dir, &dest_dir)?;868+ }869+ }870+- let html = render_release_page(rel, site_name, nav, description);871++ let html = render_release_page(rel, site_name, nav, branch, short_sha, description);872+ let page_dest = out.join("releases").join(format!("{}.html", rel.version));873+ if let Some(parent) = page_dest.parent() {874+ std::fs::create_dir_all(parent)875+@@ -486,7 +495,17 @@ mod tests {876+ std::fs::create_dir_all(&repo).unwrap();877+ let out = repo.join("out");878+ std::fs::create_dir_all(&out).unwrap();879+- render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");880++ render_loaded(881++ &[rel],882++ &repo,883++ &out,884++ "test",885++ "<nav>",886++ "main",887++ "abc1234",888++ None,889++ )890++ .expect("render");891+ let index =892+ std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");893+ assert!(894+@@ -535,7 +554,17 @@ mod tests {895+ std::fs::create_dir_all(&repo).unwrap();896+ let out = repo.join("out");897+ std::fs::create_dir_all(&out).unwrap();898+- render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");899++ render_loaded(900++ &[rel],901++ &repo,902++ &out,903++ "test",904++ "<nav>",905++ "main",906++ "abc1234",907++ None,908++ )909++ .expect("render");910+ let index =911+ std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");912+ let tds = index.matches("<td").count();913+diff --git a/src/wiki.rs b/src/wiki.rs914+index 02edd5c..746c229 100644915+--- a/src/wiki.rs916++++ b/src/wiki.rs917+@@ -15,7 +15,7 @@ use std::collections::BTreeMap;918+ use std::path::Path;919+ 920+ use crate::build::markdown_to_html;921+-use crate::layout::{esc, page_site};922++use crate::layout::{esc, page_site_with_provenance};923+ 924+ /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).925+ pub const WIKI_MAX_BYTES: u64 = 256 * 1024;926+@@ -165,6 +165,8 @@ pub fn render(927+ out: &Path,928+ site_name: &str,929+ nav: &str,930++ branch: &str,931++ short_sha: &str,932+ description: Option<&str>,933+ ) -> Result<Vec<WikiPage>, String> {934+ let root = repo.join(".forge").join("wiki");935+@@ -290,12 +292,14 @@ pub fn render(936+ }937+ std::fs::write(938+ wiki_dir.join("index.html"),939+- page_site(940++ page_site_with_provenance(941+ &format!("wiki — {site_name}"),942+ nav,943+ &body,944+ site_name,945+ description,946++ branch,947++ short_sha,948+ ),949+ )950+ .map_err(|e| format!("hu13: write wiki index: {e}"))?;951+@@ -308,12 +312,14 @@ pub fn render(952+ esc(&p.path),953+ );954+ body.push_str(&markdown_to_html(&text));955+- let html = page_site(956++ let html = page_site_with_provenance(957+ &format!("{} — wiki — {site_name}", p.title),958+ nav,959+ &body,960+ site_name,961+ description,962++ branch,963++ short_sha,964+ );965+ let dest = wiki_dir.join(format!("{}.html", p.path));966+ if let Some(parent) = dest.parent() {967+@@ -383,7 +389,7 @@ mod tests {968+ fn index_titles_sorted_skips_bad() {969+ let repo = fixture_repo();970+ let out = mktemp("out");971+- let pages = render(&repo, &out, "test", "<nav>", None).expect("render");972++ let pages = render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");973+ let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();974+ assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);975+ assert_eq!(pages[0].title, "Nested Page");976+@@ -405,7 +411,7 @@ mod tests {977+ fn pages_render_markdown_escaped() {978+ let repo = fixture_repo();979+ let out = mktemp("out");980+- render(&repo, &out, "test", "<nav>", None).expect("render");981++ render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");982+ let hello =983+ std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");984+ assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");985+@@ -426,7 +432,7 @@ mod tests {986+ fn missing_wiki_dir_renders_empty_index() {987+ let dir = mktemp("nowiki");988+ let out = mktemp("out");989+- let pages = render(&dir, &out, "test", "<nav>", None).expect("render");990++ let pages = render(&dir, &out, "test", "<nav>", "main", "abc1234", None).expect("render");991+ assert!(pages.is_empty());992+ let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");993+ assert!(index.contains("No wiki pages yet."));994+diff --git a/static/style.css b/static/style.css995+index 2efee6d..fa1b70d 100644996+--- a/static/style.css997++++ b/static/style.css998+@@ -63,9 +63,13 @@ img,video,svg{max-width:100%;height:auto}999+ a{color:var(--blue)}1000+ a:visited{color:var(--visited)}1001+ main{padding:1.25rem 0 3rem;min-width:0}1002+-/* Repo header stacks on narrow; clone cmd + sha wrap safely. */1003+-.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}1004+-.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}1005++/* Repo header stacks on narrow; clone cmd + sha wrap safely.1006++ Breathing room under the navbar (m6/code-space, f0rge #40): air via1007++ CSS margin-top (never <br> spacers); the base stays tight for 360px1008++ and wider screens get a touch more. Title is explicit 2rem so it1009++ reads as the page title on every viewport. */1010++.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}1011++.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}1012+ .clone,.repo-meta{margin:.25rem 0;min-width:0}1013+ .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}1014+ code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;1015+@@ -115,7 +119,11 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}1016+ .btn-new:visited{color:var(--btn-fg)}1017+ .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}1018+ .muted{color:var(--muted)}1019++footer.site-footer{border-top:1px solid var(--border)}1020+ footer.site-footer .wrap{padding:1rem}1021++/* Provenance line (m6/footer-line, f0rge #41): the single muted line1022++ inside the existing footer block — small, no extra whitespace. */1023++footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}1024+ /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.1025+ Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/1026+ .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav1027+@@ -136,6 +144,7 @@ footer.site-footer .wrap{padding:1rem}1028+ @media (min-width:768px){1029+ .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}1030+ .site-nav .wrap{gap:.25rem 1.25rem}1031++ .repo-head{margin-top:1.25rem}1032+ main{padding:1.5rem 0 3rem}1033+ }1034+ @media (min-width:1100px){1035+diff --git a/tests/code_space.rs b/tests/code_space.rs1036+new file mode 1006441037+index 0000000..7d73a3a1038+--- /dev/null1039++++ b/tests/code_space.rs1040+@@ -0,0 +1,184 @@1041++//! Code-tab breathing room (m6, f0rge issue #40):1042++//! the repo-head sits clear of the navbar via CSS margin (never `<br>`1043++//! spacers), with a slightly larger title — while staying tight on 360px.1044++//!1045++//! Builds a small fixture repo with the real binary and string-asserts on1046++//! the emitted HTML + `static/style.css` (no rendering engine needed).1047++1048++use std::path::{Path, PathBuf};1049++use std::process::Command;1050++1051++fn bin() -> &'static str {1052++ env!("CARGO_BIN_EXE_hu13")1053++}1054++1055++fn manifest() -> PathBuf {1056++ PathBuf::from(env!("CARGO_MANIFEST_DIR"))1057++}1058++1059++fn fresh(prefix: &str) -> PathBuf {1060++ let dir = std::env::temp_dir().join(format!(1061++ "hu13-codespace-{prefix}-{}-{}",1062++ std::process::id(),1063++ std::time::SystemTime::now()1064++ .duration_since(std::time::UNIX_EPOCH)1065++ .map(|d| d.as_nanos())1066++ .unwrap_or(0)1067++ ));1068++ std::fs::create_dir_all(&dir).unwrap();1069++ dir1070++}1071++1072++fn git(repo: &Path, args: &[&str]) {1073++ let st = Command::new("git")1074++ .arg("-C")1075++ .arg(repo)1076++ .args(args)1077++ .status()1078++ .expect("spawn git");1079++ assert!(st.success(), "git {args:?} failed");1080++}1081++1082++fn fixture_repo() -> PathBuf {1083++ let dir = fresh("repo");1084++ git(&dir, &["init", "-b", "main"]);1085++ git(&dir, &["config", "user.name", "hu13-test"]);1086++ git(&dir, &["config", "user.email", "[email protected]"]);1087++ git(&dir, &["config", "commit.gpgsign", "false"]);1088++ std::fs::write(dir.join("README.md"), "# space fixture\n\nhello\n").unwrap();1089++ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1090++ git(&dir, &["add", "-A"]);1091++ git(&dir, &["commit", "-m", "init"]);1092++ dir1093++}1094++1095++fn build_fixture() -> PathBuf {1096++ let repo = fixture_repo();1097++ let out = fresh("out");1098++ let st = Command::new(bin())1099++ .arg("build")1100++ .arg(&repo)1101++ .arg("-o")1102++ .arg(&out)1103++ .status()1104++ .expect("spawn hu13 build");1105++ assert!(st.success(), "fixture build exits 0");1106++ out1107++}1108++1109++fn css() -> String {1110++ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")1111++}1112++1113++/// Value of `prop` in the first `selector{...}` rule inside `scope`.1114++fn rule_value(scope: &str, selector: &str, prop: &str) -> Option<String> {1115++ let start = scope.find(selector)?;1116++ let block_start = scope[start..].find('{')? + start;1117++ let block_end = scope[block_start..].find('}')? + block_start;1118++ let block = &scope[block_start..block_end];1119++ let pos = block.find(prop)?;1120++ let after = block[pos + prop.len()..].trim_start().strip_prefix(':')?;1121++ let value: String = after1122++ .trim_start()1123++ .chars()1124++ .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t' | '\n'))1125++ .collect();1126++ Some(value)1127++}1128++1129++fn parse_rem(value: &str) -> Option<f32> {1130++ value.strip_suffix("rem")?.trim().parse::<f32>().ok()1131++}1132++1133++#[test]1134++fn repo_head_spacing_is_css_margin_not_br_tags() {1135++ // Structure assert: the code tab keeps its stacked repo-head1136++ // (name + meta + clone block) with air from CSS margin — no `<br>`1137++ // spacers between the navbar and the repo-head.1138++ let out = build_fixture();1139++ let index = std::fs::read_to_string(out.join("index.html")).expect("index");1140++ let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");1141++ let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;1142++ let head = &index[head_start..head_end];1143++ assert!(head.contains("<h1>"), "repo-head keeps its title");1144++ assert!(1145++ !head.contains("<br"),1146++ "spacing is CSS margin, not <br> tags"1147++ );1148++ assert!(1149++ head.contains("repo-meta"),1150++ "branch/sha meta line kept inside repo-head"1151++ );1152++ // The header bar ends (</header>) before <main> carries the repo-head:1153++ // the break between nav and repo-head is a block boundary + margin.1154++ let header_end = index.find("</header>").expect("header close");1155++ assert!(1156++ header_end < head_start,1157++ "repo-head sits below the navbar block"1158++ );1159++ let between = &index[header_end..head_start];1160++ assert!(1161++ !between.contains("<br"),1162++ "no spacer tags between navbar and repo-head"1163++ );1164++1165++ // CSS assert: `.repo-head` carries the top margin.1166++ let css = css();1167++ let base = css.split("@media (min-width").next().expect("base css");1168++ let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");1169++ assert!(1170++ parse_rem(&margin).is_some_and(|v| v > 0.0),1171++ "repo-head margin-top is positive breathing room, got {margin:?}"1172++ );1173++}1174++1175++#[test]1176++fn repo_head_title_reads_as_page_title() {1177++ // Slightly larger title treatment: explicit size on the repo-head h1.1178++ let css = css();1179++ let base = css.split("@media (min-width").next().expect("base css");1180++ let size = rule_value(base, ".repo-head h1", "font-size").expect("repo-head h1 font-size");1181++ let rem = parse_rem(&size).expect("h1 size in rem");1182++ assert!(1183++ rem >= 1.5,1184++ "repo-head title is title-scale (>=1.5rem), got {size:?}"1185++ );1186++ let out = build_fixture();1187++ let index = std::fs::read_to_string(out.join("index.html")).expect("index");1188++ assert!(1189++ index.contains("<div class=\"repo-head\"><h1>"),1190++ "repo-head h1 opens the code tab body"1191++ );1192++}1193++1194++#[test]1195++fn mobile_stays_tight_wider_screens_get_more() {1196++ // 360px must not drown in whitespace: the base (pre-media-query)1197++ // margin stays modest; >=768px may open up a touch more.1198++ let css = css();1199++ let base = css.split("@media (min-width").next().expect("base css");1200++ let base_margin =1201++ rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");1202++ let base_rem = parse_rem(&base_margin).expect("base margin in rem");1203++ assert!(1204++ base_rem <= 1.0,1205++ "mobile margin-top stays tight (<=1rem), got {base_margin:?}"1206++ );1207++ let desktop = css1208++ .split("@media (min-width:768px)")1209++ .nth(1)1210++ .expect("768px breakpoint");1211++ // Only the current breakpoint block: cut before the next @media.1212++ let block = desktop.split("@media").next().expect("768 block");1213++ let wide_margin =1214++ rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");1215++ let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");1216++ assert!(1217++ wide_rem >= base_rem,1218++ "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"1219++ );1220++ assert!(1221++ wide_rem <= 2.0,1222++ "even wide stays modest (<=2rem), got {wide_margin:?}"1223++ );1224++}1225+diff --git a/tests/provenance.rs b/tests/provenance.rs1226+new file mode 1006441227+index 0000000..dff7e551228+--- /dev/null1229++++ b/tests/provenance.rs1230+@@ -0,0 +1,161 @@1231++//! Footer provenance line (m6, f0rge issue #41):1232++//! every page extends the existing footer block with a single muted line1233++//! (`generated from a snapshot of a repo and its f0rge` + branch@sha).1234++//!1235++//! Builds a small fixture repo with the real binary and string-asserts on1236++//! the emitted HTML + `static/style.css` (no rendering engine needed).1237++1238++use std::path::{Path, PathBuf};1239++use std::process::Command;1240++1241++fn bin() -> &'static str {1242++ env!("CARGO_BIN_EXE_hu13")1243++}1244++1245++fn manifest() -> PathBuf {1246++ PathBuf::from(env!("CARGO_MANIFEST_DIR"))1247++}1248++1249++fn fresh(prefix: &str) -> PathBuf {1250++ let dir = std::env::temp_dir().join(format!(1251++ "hu13-provenance-{prefix}-{}-{}",1252++ std::process::id(),1253++ std::time::SystemTime::now()1254++ .duration_since(std::time::UNIX_EPOCH)1255++ .map(|d| d.as_nanos())1256++ .unwrap_or(0)1257++ ));1258++ std::fs::create_dir_all(&dir).unwrap();1259++ dir1260++}1261++1262++fn git(repo: &Path, args: &[&str]) {1263++ let st = Command::new("git")1264++ .arg("-C")1265++ .arg(repo)1266++ .args(args)1267++ .status()1268++ .expect("spawn git");1269++ assert!(st.success(), "git {args:?} failed");1270++}1271++1272++fn git_out(repo: &Path, args: &[&str]) -> String {1273++ let out = Command::new("git")1274++ .arg("-C")1275++ .arg(repo)1276++ .args(args)1277++ .output()1278++ .expect("spawn git");1279++ assert!(out.status.success(), "git {args:?} failed");1280++ String::from_utf8_lossy(&out.stdout).trim().to_string()1281++}1282++1283++/// Fixture with two commits so list + detail pages exist.1284++fn fixture_repo() -> PathBuf {1285++ let dir = fresh("repo");1286++ git(&dir, &["init", "-b", "main"]);1287++ git(&dir, &["config", "user.name", "hu13-test"]);1288++ git(&dir, &["config", "user.email", "[email protected]"]);1289++ git(&dir, &["config", "commit.gpgsign", "false"]);1290++ std::fs::write(dir.join("README.md"), "# provenance fixture\n\nhello\n").unwrap();1291++ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1292++ git(&dir, &["add", "-A"]);1293++ git(&dir, &["commit", "-m", "first"]);1294++ std::fs::write(dir.join("hello.txt"), "hello again\n").unwrap();1295++ git(&dir, &["add", "-A"]);1296++ git(&dir, &["commit", "-m", "second"]);1297++ dir1298++}1299++1300++fn footer_of(html: &str) -> &str {1301++ let start = html.find("<footer").expect("footer open");1302++ let end = html.find("</footer>").expect("footer close") + "</footer>".len();1303++ &html[start..end]1304++}1305++1306++#[test]1307++fn footer_provenance_on_index_and_detail_pages() {1308++ let repo = fixture_repo();1309++ let sha = git_out(&repo, &["rev-parse", "--short", "HEAD"]);1310++ assert!(!sha.is_empty(), "fixture has a short sha");1311++ let out = fresh("out");1312++ let st = Command::new(bin())1313++ .arg("build")1314++ .arg(&repo)1315++ .arg("-o")1316++ .arg(&out)1317++ .status()1318++ .expect("spawn hu13 build");1319++ assert!(st.success(), "fixture build exits 0");1320++1321++ // One commit-detail page exists.1322++ let commit_page = std::fs::read_dir(out.join("commit"))1323++ .expect("commit dir")1324++ .filter_map(|e| e.ok())1325++ .map(|e| e.path())1326++ .find(|p| p.extension().is_some_and(|x| x == "html"))1327++ .expect("one commit page");1328++1329++ // Index + a detail page (commit detail) + breadth across tabs.1330++ let mut pages = vec![1331++ out.join("index.html"),1332++ commit_page,1333++ out.join("commits.html"),1334++ out.join("files").join("hello.txt.html"),1335++ out.join("issues/index.html"),1336++ out.join("agents.html"),1337++ out.join("new-issue.html"),1338++ ];1339++ // Wiki/releases tabs render even when empty (deep links keep working).1340++ pages.push(out.join("wiki/index.html"));1341++ pages.push(out.join("releases/index.html"));1342++ for page in &pages {1343++ let html =1344++ std::fs::read_to_string(page).unwrap_or_else(|_| panic!("read {}", page.display()));1345++ assert_eq!(1346++ html.matches("<footer").count(),1347++ 1,1348++ "{} extends the existing footer (exactly one)",1349++ page.display()1350++ );1351++ assert!(1352++ html.contains("<footer class=\"site-footer\">"),1353++ "{} keeps the footer block",1354++ page.display()1355++ );1356++ assert!(1357++ html.contains("generated from a snapshot of a repo and its f0rge"),1358++ "{} carries the provenance line",1359++ page.display()1360++ );1361++ assert!(1362++ html.contains(&format!("main @ {sha}")),1363++ "{} names the built snapshot",1364++ page.display()1365++ );1366++ // Muted, single line: no spacers inside the footer.1367++ let footer = footer_of(&html);1368++ assert!(1369++ footer.contains("muted"),1370++ "{} footer is muted",1371++ page.display()1372++ );1373++ assert!(1374++ !footer.contains("<br"),1375++ "{} footer is a single line",1376++ page.display()1377++ );1378++ }1379++}1380++1381++#[test]1382++fn footer_styling_is_muted_and_tiny() {1383++ let css = std::fs::read_to_string(manifest().join("static").join("style.css"))1384++ .expect("read style.css");1385++ assert!(css.contains("footer.site-footer"), "footer block styled");1386++ assert!(1387++ css.contains(".footer-provenance"),1388++ "provenance line has its own rule"1389++ );1390++ assert!(css.contains(".muted"), "muted tone reused, not a new color");1391++}1392+-- 1393+2.55.01394+