land #40+#41 + f0rge state

a35e3a2gl0w

all commits

Message

land #40+#41 + f0rge state

Diff

.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/comments/962641b1-d6b4-4561-afe8-5c2b7e30096b.md+6 −0
diff --git a/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/comments/962641b1-d6b4-4561-afe8-5c2b7e30096b.md b/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/comments/962641b1-d6b4-4561-afe8-5c2b7e30096b.mdnew file mode 100644index 0000000..61217db--- /dev/null+++ b/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/comments/962641b1-d6b4-4561-afe8-5c2b7e30096b.md@@ -0,0 +1,6 @@1+---2+id: 962641b1-d6b4-4561-afe8-5c2b7e30096b3+author: null4+created: 2026-09-10T07:56:15.352777943Z5+---6+Merged PR b7b3fac0 (m6/finish-line, joint #41): repo-head spacing + footer provenance on all pages. CI pass. Rubric verified.
.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/issue.md+2 −2
diff --git a/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/issue.md b/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/issue.mdindex 1d10f80..1cf0a3c 100644--- a/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/issue.md+++ b/.forge/issues/631ddfae-f5bf-4534-8c68-2508cfc1247a/issue.md@@ -1,13 +1,13 @@11 ---22 id: 631ddfae-f5bf-4534-8c68-2508cfc1247a33 title: 'Code page: breathing room under navbar'4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T07:32:34.563544442Z10-updated: null10+updated: 2026-09-10T07:56:15.356477550Z1111 ---1212 Context: Owner QA — code page repo-head crowds the navbar; needs air.1313 Tasks:
.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/comments/c9d38345-0c55-42ef-8a66-a1e6f850de30.md+6 −0
diff --git a/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/comments/c9d38345-0c55-42ef-8a66-a1e6f850de30.md b/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/comments/c9d38345-0c55-42ef-8a66-a1e6f850de30.mdnew file mode 100644index 0000000..b3ed59f--- /dev/null+++ b/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/comments/c9d38345-0c55-42ef-8a66-a1e6f850de30.md@@ -0,0 +1,6 @@1+---2+id: c9d38345-0c55-42ef-8a66-a1e6f850de303+author: null4+created: 2026-09-10T07:56:15.372351462Z5+---6+Covered by same PR b7b3fac0. Footer live on every page. Closed.
.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/issue.md+2 −2
diff --git a/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/issue.md b/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/issue.mdindex 91f5e68..191c305 100644--- a/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/issue.md+++ b/.forge/issues/cac940be-ef71-4ce6-9bef-01b57a333f6d/issue.md@@ -1,13 +1,13 @@11 ---22 id: cac940be-ef71-4ce6-9bef-01b57a333f6d33 title: Footer provenance line on all pages4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T07:32:34.588544523Z10-updated: null10+updated: 2026-09-10T07:56:15.375447823Z1111 ---1212 Context: Owner QA — every page should carry provenance.1313 Tasks:
.forge/merges/2026-09-10T07-54-47.583557379Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T07-54-47.583557379Z.md b/.forge/merges/2026-09-10T07-54-47.583557379Z.mdnew file mode 100644index 0000000..5681067--- /dev/null+++ b/.forge/merges/2026-09-10T07-54-47.583557379Z.md@@ -0,0 +1,10 @@1+---2+pr: b7b3fac0-57b2-4497-9a52-e0fef1a465423+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T07:54:47.583557379Z7+---8+applied: 7d9a77109188ee509abfc3f8bf2135133115844c9+patch: 08ab9ec287c93407fbaf3ddf0605b2932f8de02c10+pass.
.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/patch.diff+1394 −0
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 `&amp;` 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("&lt;script&gt;alert(1)&lt;/script&gt; @ 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+
.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/pr.md+12 −0
diff --git a/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/pr.md b/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/pr.mdnew file mode 100644index 0000000..44cef98--- /dev/null+++ b/.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/pr.md@@ -0,0 +1,12 @@1+---2+id: b7b3fac0-57b2-4497-9a52-e0fef1a465423+title: m6/finish-line4+state: merged5+base: master6+head: m6/finish-line7+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T07:54:47.554506100Z11+updated: 2026-09-10T07:55:18.707777059Z12+---