m6/finish-line: repo-head spacing + footer provenance

985dba6gl0w

all commits

Message

m6/finish-line: repo-head spacing + footer provenance

Diff

src/agents.rs+4 −2
diff --git a/src/agents.rs b/src/agents.rsindex f0f3646..d4e49f1 100644--- a/src/agents.rs+++ b/src/agents.rs@@ -15,7 +15,7 @@1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page_site};18+use crate::layout::{esc, page_site_with_provenance};1919 2020 /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).2121 /// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,@@ -114,12 +114,14 @@ pub fn render(114114     // source so the raw `agents.md` and rendered `agents.html` agree. The115115     // markdown renderer escapes raw HTML again, so `&` etc. stay safe.116116     let html_body = markdown_to_html(&md);117-    let html = page_site(117+    let html = page_site_with_provenance(118118         &format!("agents — {site_name}"),119119         nav,120120         &html_body,121121         site_name,122122         description,123+        branch,124+        short_sha,123125     );124126     std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;125127     std::fs::write(out.join("agents.html"), html)
src/build.rs+44 −9
diff --git a/src/build.rs b/src/build.rsindex 96c819f..5d176ba 100644--- a/src/build.rs+++ b/src/build.rs@@ -941,8 +941,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu941941             Some((&parent_href, &parent_label)),942942             &history,943943         ));944-        let html =945-            crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);944+        let html = crate::layout::page_site_with_provenance(945+            &format!("{dir} — {name}"),946+            &nav,947+            &body,948+            &name,949+            desc_opt,950+            &branch,951+            &short_sha,952+        );946953         let dest = out.join("files").join(format!("{dir}.html"));947954         if let Some(parent) = dest.parent() {948955             std::fs::create_dir_all(parent)@@ -988,7 +995,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu988995             ));989996             body.push_str(&render_text_pre(&display));990997         }991-        let html = crate::layout::page_site(&format!("{f} — {name}"), &nav, &body, &name, desc_opt);998+        let html = crate::layout::page_site_with_provenance(999+            &format!("{f} — {name}"),1000+            &nav,1001+            &body,1002+            &name,1003+            desc_opt,1004+            &branch,1005+            &short_sha,1006+        );9921007         let dest = out.join("files").join(format!("{f}.html"));9931008         if let Some(parent) = dest.parent() {9941009             std::fs::create_dir_all(parent)@@ -1000,15 +1015,33 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10001015     // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):10011016     // one struct pass — the same loaded vecs feed both HTML and JSON.10021017     let commit_log = commits::load(repo, &branch, opts.max_log)?;1003-    commits::render_loaded(repo, out, &commit_log, &name, &nav, desc_opt)?;1018+    commits::render_loaded(1019+        repo,1020+        out,1021+        &commit_log,1022+        &name,1023+        &nav,1024+        &branch,1025+        &short_sha,1026+        desc_opt,1027+    )?;10041028     feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;1005-    let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;1029+    let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;10061030     // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.10071031     // Tasks/releases/wiki indexes always render even when empty (m610081032     // hide-empty, f0rge #38): only the nav hides the tab, deep links10091033     // keep working with their "No X yet" empty states.1010-    forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;1011-    releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;1034+    forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;1035+    releases::render_loaded(1036+        &release_data,1037+        repo,1038+        out,1039+        &name,1040+        &nav,1041+        &branch,1042+        &short_sha,1043+        desc_opt,1044+    )?;10121045     api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;10131046     agents::render(10141047         out,@@ -1020,7 +1053,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10201053         desc_opt,10211054     )?;10221055     // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.1023-    crate::inbox::render_forms(out, &name, &nav, desc_opt)?;1056+    crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;10241057 10251058     // Snapshot last so the index can link sizes.10261059     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;@@ -1093,7 +1126,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10931126     }10941127     body.push_str("</section></div>\n");10951128 1096-    let index = crate::layout::page_site(&name, &nav, &body, &name, desc_opt);1129+    let index = crate::layout::page_site_with_provenance(1130+        &name, &nav, &body, &name, desc_opt, &branch, &short_sha,1131+    );10971132     std::fs::write(out.join("index.html"), index)10981133         .map_err(|e| format!("hu13: write index.html: {e}"))?;10991134 
src/commits.rs+21 −4
diff --git a/src/commits.rs b/src/commits.rsindex 7075113..60566f2 100644--- a/src/commits.rs+++ b/src/commits.rs@@ -9,7 +9,7 @@99 use std::path::Path;1010 1111 use crate::gitx;12-use crate::layout::{diff_html, esc, page_site};12+use crate::layout::{diff_html, esc, page_site_with_provenance};1313 1414 /// Short display form of a full sha (first 7 hex chars, git-style).1515 #[must_use]@@ -25,12 +25,15 @@ pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commi2525 2626 /// Render `commits.html` plus one `commit/<sha>.html` per commit from an2727 /// already-loaded log (see [`load`]).28+#[allow(clippy::too_many_arguments)]2829 pub fn render_loaded(2930     repo: &Path,3031     out: &Path,3132     log: &[gitx::Commit],3233     site_name: &str,3334     nav: &str,35+    branch: &str,36+    snap_sha: &str,3437     description: Option<&str>,3538 ) -> Result<usize, String> {3639     let mut body = String::from("<h1>Commits</h1>\n");@@ -50,12 +53,14 @@ pub fn render_loaded(5053         }5154         body.push_str("</tbody>\n</table></div>\n");5255     }53-    let html = page_site(56+    let html = page_site_with_provenance(5457         &format!("commits — {site_name}"),5558         nav,5659         &body,5760         site_name,5861         description,62+        branch,63+        snap_sha,5964     );6065     std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;6166     std::fs::write(out.join("commits.html"), html)@@ -75,12 +80,14 @@ pub fn render_loaded(7580             esc(&message),7681             diff_html(&diff),7782         );78-        let html = page_site(83+        let html = page_site_with_provenance(7984             &format!("commit {} — {site_name}", short_sha(&c.sha)),8085             nav,8186             &detail,8287             site_name,8388             description,89+            branch,90+            snap_sha,8491         );8592         std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)8693             .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;@@ -103,7 +110,17 @@ pub fn render(103110     description: Option<&str>,104111 ) -> Result<usize, String> {105112     let log = load(repo, branch, max_log)?;106-    render_loaded(repo, out, &log, site_name, nav, description)113+    let snap_sha = gitx::rev_parse_short(repo, branch).unwrap_or_default();114+    render_loaded(115+        repo,116+        out,117+        &log,118+        site_name,119+        nav,120+        branch,121+        &snap_sha,122+        description,123+    )107124 }108125 109126 #[cfg(test)]
src/forge.rs+78 −25
diff --git a/src/forge.rs b/src/forge.rsindex ff7f1cb..ce85610 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -23,7 +23,7 @@ use std::path::Path;2323 use serde::Deserialize;2424 2525 use crate::build::markdown_to_html;26-use crate::layout::{diff_html, esc, page_site};26+use crate::layout::{diff_html, esc, page_site_with_provenance};2727 2828 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2929 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;@@ -830,6 +830,8 @@ fn render_issue_page(830830     issue: &Issue,831831     site_name: &str,832832     nav: &str,833+    branch: &str,834+    short_sha: &str,833835     description: Option<&str>,834836 ) -> String {835837     let mut body = format!(@@ -879,16 +881,25 @@ fn render_issue_page(879881         body.push_str("</ul>\n");880882     }881883     body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");882-    page_site(884+    page_site_with_provenance(883885         &format!("#{} {} — {site_name}", issue.number, issue.title),884886         nav,885887         &body,886888         site_name,887889         description,890+        branch,891+        short_sha,888892     )889893 }890894 891-fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>) -> String {895+fn render_pr_page(896+    pr: &Pr,897+    site_name: &str,898+    nav: &str,899+    branch: &str,900+    short_sha: &str,901+    description: Option<&str>,902+) -> String {892903     let mut body = format!(893904         "<h1>#{n} {title}</h1>\n",894905         n = pr.number,@@ -963,12 +974,14 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>963974         body.push_str("</ul>\n");964975     }965976     body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");966-    page_site(977+    page_site_with_provenance(967978         &format!("#{} {} — {site_name}", pr.number, pr.title),968979         nav,969980         &body,970981         site_name,971982         description,983+        branch,984+        short_sha,972985     )973986 }974987 @@ -976,6 +989,8 @@ fn render_issues_index(976989     data: &ForgeData,977990     site_name: &str,978991     nav: &str,992+    branch: &str,993+    short_sha: &str,979994     description: Option<&str>,980995 ) -> Result<String, String> {981996     let mut body =@@ -1019,12 +1034,14 @@ fn render_issues_index(10191034         }10201035         body.push_str("</ul>\n");10211036     }1022-    Ok(page_site(1037+    Ok(page_site_with_provenance(10231038         &format!("issues — {site_name}"),10241039         nav,10251040         &body,10261041         site_name,10271042         description,1043+        branch,1044+        short_sha,10281045     ))10291046 }10301047 @@ -1032,6 +1049,8 @@ fn render_pulls_index(10321049     data: &ForgeData,10331050     site_name: &str,10341051     nav: &str,1052+    branch: &str,1053+    short_sha: &str,10351054     description: Option<&str>,10361055 ) -> String {10371056     let prs = &data.prs;@@ -1059,12 +1078,14 @@ fn render_pulls_index(10591078         "<p class=\"muted\">{n} closed</p>\n",10601079         n = data.closed_pr_count(),10611080     ));1062-    page_site(1081+    page_site_with_provenance(10631082         &format!("pulls — {site_name}"),10641083         nav,10651084         &body,10661085         site_name,10671086         description,1087+        branch,1088+        short_sha,10681089     )10691090 }10701091 @@ -1072,6 +1093,8 @@ fn render_tasks_index(10721093     tasks: &[TaskItem],10731094     site_name: &str,10741095     nav: &str,1096+    branch: &str,1097+    short_sha: &str,10751098     description: Option<&str>,10761099 ) -> String {10771100     let mut body = String::from("<h1>Tasks</h1>\n");@@ -1107,16 +1130,25 @@ fn render_tasks_index(11071130             }11081131         }11091132     }1110-    page_site(1133+    page_site_with_provenance(11111134         &format!("tasks — {site_name}"),11121135         nav,11131136         &body,11141137         site_name,11151138         description,1139+        branch,1140+        short_sha,11161141     )11171142 }11181143 1119-fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<&str>) -> String {1144+fn render_ci_index(1145+    ci: &CiInfo,1146+    site_name: &str,1147+    nav: &str,1148+    branch: &str,1149+    short_sha: &str,1150+    description: Option<&str>,1151+) -> String {11201152     let mut body = String::from("<h1>CI</h1>\n");11211153     body.push_str(&meta_row("runner:", &esc(&ci.runner)));11221154     if ci.steps.is_empty() {@@ -1170,12 +1202,14 @@ fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str, description: Option<11701202         }11711203         None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),11721204     }1173-    page_site(1205+    page_site_with_provenance(11741206         &format!("ci — {site_name}"),11751207         nav,11761208         &body,11771209         site_name,11781210         description,1211+        branch,1212+        short_sha,11791213     )11801214 }11811215 @@ -1193,43 +1227,45 @@ pub fn render_loaded(11931227     out: &Path,11941228     site_name: &str,11951229     nav: &str,1230+    branch: &str,1231+    short_sha: &str,11961232     description: Option<&str>,11971233 ) -> Result<(), String> {11981234     write_page(11991235         out,12001236         "issues/index.html",1201-        &render_issues_index(data, site_name, nav, description)?,1237+        &render_issues_index(data, site_name, nav, branch, short_sha, description)?,12021238     )?;12031239     for issue in &data.issues {12041240         write_page(12051241             out,12061242             &format!("issues/{}.html", issue.id),1207-            &render_issue_page(issue, site_name, nav, description),1243+            &render_issue_page(issue, site_name, nav, branch, short_sha, description),12081244         )?;12091245     }12101246 12111247     write_page(12121248         out,12131249         "pulls/index.html",1214-        &render_pulls_index(data, site_name, nav, description),1250+        &render_pulls_index(data, site_name, nav, branch, short_sha, description),12151251     )?;12161252     for pr in &data.prs {12171253         write_page(12181254             out,12191255             &format!("pulls/{}.html", pr.id),1220-            &render_pr_page(pr, site_name, nav, description),1256+            &render_pr_page(pr, site_name, nav, branch, short_sha, description),12211257         )?;12221258     }12231259 12241260     write_page(12251261         out,12261262         "tasks/index.html",1227-        &render_tasks_index(&data.tasks, site_name, nav, description),1263+        &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),12281264     )?;12291265     write_page(12301266         out,12311267         "ci/index.html",1232-        &render_ci_index(&data.ci, site_name, nav, description),1268+        &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),12331269     )?;12341270 12351271     Ok(())@@ -1360,7 +1396,8 @@ mod tests {13601396         assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));13611397 13621398         let out = mktemp("pr-out");1363-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1399+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1400+            .expect("render");13641401         let html =13651402             std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))13661403                 .expect("pr page");@@ -1389,7 +1426,8 @@ mod tests {13891426         )13901427         .unwrap();13911428         let out = mktemp("pr-diff-out");1392-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1429+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1430+            .expect("render");13931431         let html =13941432             std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))13951433                 .expect("pr page");@@ -1450,7 +1488,8 @@ mod tests {14501488         assert_eq!(data.ci.latest_verdict(), "fail");14511489 14521490         let out = mktemp("taskci-out");1453-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1491+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1492+            .expect("render");14541493         let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");14551494         for status in ["todo", "doing", "blocked", "done"] {14561495             assert!(tasks.contains(status), "group {status}");@@ -1471,7 +1510,8 @@ mod tests {14711510         assert!(data.tasks.is_empty());14721511         assert_eq!(data.ci.latest_verdict(), "none");14731512         let out = mktemp("empty-out");1474-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1513+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1514+            .expect("render");14751515         for page in [14761516             "issues/index.html",14771517             "pulls/index.html",@@ -1490,7 +1530,8 @@ mod tests {14901530         // Empty forge: buttons + linked empty states.14911531         let repo = mktemp("submit-empty");14921532         let out = mktemp("submit-empty-out");1493-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1533+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1534+            .expect("render");14941535         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");14951536         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");14961537         assert!(@@ -1523,7 +1564,16 @@ mod tests {15231564             "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",15241565         );15251566         let out2 = mktemp("submit-full-out");1526-        render_loaded(&load(&repo), &out2, "test", "<nav>", None).expect("render");1567+        render_loaded(1568+            &load(&repo),1569+            &out2,1570+            "test",1571+            "<nav>",1572+            "main",1573+            "abc1234",1574+            None,1575+        )1576+        .expect("render");15271577         let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");15281578         let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");15291579         assert!(@@ -1641,7 +1691,8 @@ mod tests {16411691         assert!(css.contains("align-items:center"), "inline row");16421692         let repo = mktemp("page-head");16431693         let out = mktemp("page-head-out");1644-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1694+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1695+            .expect("render");16451696         for (page, title, btn) in [16461697             ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),16471698             ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),@@ -1683,7 +1734,8 @@ mod tests {16831734             ),16841735         );16851736         let out = mktemp("long-title-out");1686-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1737+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1738+            .expect("render");16871739         for page in ["issues/index.html", "pulls/index.html"] {16881740             let html = std::fs::read_to_string(out.join(page)).expect(page);16891741             assert!(@@ -1741,7 +1793,7 @@ mod tests {17411793         assert_eq!(data.closed_pr_count(), 1, "merged");17421794 17431795         let out = mktemp("open-counts-out");1744-        render_loaded(&data, &out, "test", "<nav>", None).expect("render");1796+        render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");17451797         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");17461798         assert!(issues.contains("2 closed"), "issues closed total");17471799         assert!(issues.contains("Issue open"), "open row kept");@@ -1806,7 +1858,8 @@ mod tests {18061858             "---\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",18071859         );18081860         let out = mktemp("cards-out");1809-        render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");1861+        render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1862+            .expect("render");18101863         for (page, labels) in [18111864             (18121865                 "issues/index.html",
src/inbox.rs+14 −4
diff --git a/src/inbox.rs b/src/inbox.rsindex 9806475..ecceb1d 100644--- a/src/inbox.rs+++ b/src/inbox.rs@@ -17,7 +17,7 @@1717 1818 use std::path::Path;1919 20-use crate::layout::{esc, page, page_site};20+use crate::layout::{esc, page, page_site_with_provenance};2121 2222 /// Max chars for `title` (both kinds).2323 pub const MAX_TITLE_CHARS: usize = 300;@@ -614,6 +614,8 @@ fn form_page(614614     action: &str,615615     body_name: &str,616616     body_label: &str,617+    branch: &str,618+    short_sha: &str,617619     description: Option<&str>,618620 ) -> String {619621     let body = format!(@@ -649,12 +651,14 @@ fn form_page(649651         body_label = esc(body_label),650652         license = license_block(),651653     );652-    page_site(654+    page_site_with_provenance(653655         &format!("{heading} — {site_name}"),654656         nav,655657         &body,656658         site_name,657659         description,660+        branch,661+        short_sha,658662     )659663 }660664 @@ -663,6 +667,8 @@ pub fn render_forms(663667     out: &Path,664668     site_name: &str,665669     nav: &str,670+    branch: &str,671+    short_sha: &str,666672     description: Option<&str>,667673 ) -> Result<(), String> {668674     let issue = form_page(@@ -673,6 +679,8 @@ pub fn render_forms(673679         InboxType::Issue.route(),674680         "body",675681         "Body",682+        branch,683+        short_sha,676684         description,677685     );678686     let pr = form_page(@@ -683,6 +691,8 @@ pub fn render_forms(683691         InboxType::PrPrompt.route(),684692         "prompt",685693         "Prompt",694+        branch,695+        short_sha,686696         description,687697     );688698     std::fs::write(out.join("new-issue.html"), issue)@@ -1036,7 +1046,7 @@ mod tests {10361046                 .unwrap_or(0)10371047         ));10381048         std::fs::create_dir_all(&dir).unwrap();1039-        render_forms(&dir, "test", "", None).unwrap();1049+        render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();10401050         let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();10411051         let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();10421052         assert!(issue.contains("action=\"/inbox/issue\""));@@ -1071,7 +1081,7 @@ mod tests {10711081                 .unwrap_or(0)10721082         ));10731083         std::fs::create_dir_all(&dir).unwrap();1074-        render_forms(&dir, "test", "", None).unwrap();1084+        render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();10751085         let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();10761086         let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();10771087         for html in [&issue, &pr] {
src/layout.rs+122 −0
diff --git a/src/layout.rs b/src/layout.rsindex 430c397..096a227 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -186,6 +186,27 @@ pub fn apply_active(nav: &str, href: &str) -> String {186186     nav.to_string()187187 }188188 189+/// Provenance footer line (m6 finish-line, f0rge #41): a single muted190+/// line carried on EVERY page — `generated from a snapshot of a repo191+/// and its f0rge`, plus `branch @ short_sha` when the caller knows the192+/// snapshot (build pages do; serve-time stubs don't). Extends the193+/// existing `footer.site-footer` block: exactly one per page, never194+/// duplicated. `branch`/`short_sha` go through [`esc`] (untrusted195+/// config/branch names render as text).196+#[must_use]197+pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> String {198+    let mut line = String::from("generated from a snapshot of a repo and its f0rge");199+    if let (Some(b), Some(s)) = (branch, short_sha) {200+        // Empty snapshot ids degrade to the generic line (never `main @ `).201+        if !b.is_empty() && !s.is_empty() {202+            line.push_str(&format!(" · {} @ {}", esc(b), esc(s)));203+        }204+    }205+    format!(206+        "<footer class=\"site-footer\"><div class=\"wrap\"><p class=\"muted footer-provenance\">{line}</p></div></footer>"207+    )208+}209+189210 #[must_use]190211 pub fn page_site(191212     title: &str,@@ -193,6 +214,45 @@ pub fn page_site(193214     body: &str,194215     site_name: &str,195216     description: Option<&str>,217+) -> String {218+    page_site_inner(title, nav, body, site_name, description, None, None)219+}220+221+/// [`page_site`] plus snapshot provenance in the footer (m6 finish-line,222+/// f0rge #41): `branch`/`short_sha` name the built snapshot so every223+/// build page carries `branch @ sha` after the generic line. Callers224+/// without a repo snapshot (serve 404s, inbox acks) use [`page_site`]225+/// and get the generic line alone.226+#[must_use]227+pub fn page_site_with_provenance(228+    title: &str,229+    nav: &str,230+    body: &str,231+    site_name: &str,232+    description: Option<&str>,233+    branch: &str,234+    short_sha: &str,235+) -> String {236+    page_site_inner(237+        title,238+        nav,239+        body,240+        site_name,241+        description,242+        Some(branch),243+        Some(short_sha),244+    )245+}246+247+#[must_use]248+fn page_site_inner(249+    title: &str,250+    nav: &str,251+    body: &str,252+    site_name: &str,253+    description: Option<&str>,254+    branch: Option<&str>,255+    short_sha: Option<&str>,196256 ) -> String {197257     let title_e = esc(title);198258     // One-bar minimal header (m6 navbar #18, logo-text #27 + #28): the@@ -227,6 +287,7 @@ pub fn page_site(227287         Some(href) => apply_active(nav, href),228288         None => nav.to_string(),229289     };290+    let footer = provenance_footer(branch, short_sha);230291     format!(231292         "<!DOCTYPE html>\n\232293 <html lang=\"en\">\n\@@ -234,6 +295,7 @@ pub fn page_site(234295 <body>\n\235296 <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\236297 <main class=\"wrap\">\n{body}\n</main>\n\298+{footer}\n\237299 </body>\n\238300 </html>\n"239301     )@@ -484,4 +546,64 @@ mod tests {484546         assert!(html.contains("Binary files"), "binary note intact");485547         assert!(!html.contains("<span>\n[hu13"), "markers have no span");486548     }549+550+    #[test]551+    fn every_page_carries_a_single_muted_provenance_footer() {552+        // m6 footer-line (f0rge #41): the existing footer block carries553+        // one muted provenance line; serve stubs without a snapshot554+        // get the generic line alone.555+        use super::{page_site_with_provenance, provenance_footer};556+        for html in [557+            page("t", "<a href=\"/\">home</a>", "<p>b</p>"),558+            page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234"),559+        ] {560+            assert_eq!(html.matches("<footer").count(), 1, "exactly one footer");561+            assert!(562+                html.contains("<footer class=\"site-footer\">"),563+                "existing footer block extended, not duplicated"564+            );565+            assert!(566+                html.contains("generated from a snapshot of a repo and its f0rge"),567+                "provenance line present"568+            );569+            assert!(570+                html.contains("class=\"muted footer-provenance\""),571+                "muted styling via .muted"572+            );573+            let f0 = html.find("<footer").expect("footer open");574+            let f1 = html.find("</footer>").expect("footer close");575+            assert!(!html[f0..f1].contains("<br"), "single line, no spacers");576+        }577+        // Generic footer names no snapshot.578+        assert!(579+            !provenance_footer(None, None).contains('@'),580+            "no sha when unknown"581+        );582+        // Empty ids degrade to the generic line, never `main @ `.583+        assert!(!provenance_footer(Some("main"), Some("")).contains('@'));584+    }585+586+    #[test]587+    fn provenance_footer_names_snapshot_and_escapes() {588+        // m6 footer-line (f0rge #41): build pages name branch @ sha;589+        // hostile branch names render as text.590+        use super::page_site_with_provenance;591+        let html =592+            page_site_with_provenance("t", "<nav></nav>", "<p>b</p>", "s", None, "main", "abc1234");593+        assert!(html.contains("main @ abc1234"), "branch@sha named: {html}");594+        let evil = page_site_with_provenance(595+            "t",596+            "<nav></nav>",597+            "<p>b</p>",598+            "s",599+            None,600+            "<script>alert(1)</script>",601+            "abc1234",602+        );603+        assert!(!evil.contains("<script>"), "branch escaped: {evil}");604+        assert!(605+            evil.contains("&lt;script&gt;alert(1)&lt;/script&gt; @ abc1234"),606+            "escaped branch kept with sha"607+        );608+    }487609 }
src/releases.rs+35 −6
diff --git a/src/releases.rs b/src/releases.rsindex 87493bb..507c24e 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -22,7 +22,7 @@ use std::path::{Path, PathBuf};2222 use serde::Deserialize;2323 2424 use crate::build::markdown_to_html;25-use crate::layout::{esc, page_site};25+use crate::layout::{esc, page_site_with_provenance};2626 2727 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).2828 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;@@ -322,6 +322,8 @@ fn render_release_page(322322     rel: &Release,323323     site_name: &str,324324     nav: &str,325+    branch: &str,326+    short_sha: &str,325327     description: Option<&str>,326328 ) -> String {327329     let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));@@ -364,23 +366,28 @@ fn render_release_page(364366         }365367     }366368     body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");367-    page_site(369+    page_site_with_provenance(368370         &format!("{} — releases — {site_name}", rel.display_title()),369371         nav,370372         &body,371373         site_name,372374         description,375+        branch,376+        short_sha,373377     )374378 }375379 376380 /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded377381 /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.382+#[allow(clippy::too_many_arguments)]378383 pub fn render_loaded(379384     releases: &[Release],380385     repo: &Path,381386     out: &Path,382387     site_name: &str,383388     nav: &str,389+    branch: &str,390+    short_sha: &str,384391     description: Option<&str>,385392 ) -> Result<(), String> {386393     let root = repo.join(".forge").join("releases");@@ -401,12 +408,14 @@ pub fn render_loaded(401408         }402409         body.push_str("</tbody>\n</table></div>\n");403410     }404-    let index = page_site(411+    let index = page_site_with_provenance(405412         &format!("releases — {site_name}"),406413         nav,407414         &body,408415         site_name,409416         description,417+        branch,418+        short_sha,410419     );411420     let dest = out.join("releases").join("index.html");412421     if let Some(parent) = dest.parent() {@@ -423,7 +432,7 @@ pub fn render_loaded(423432                 copy_tree(&src_dir, &dest_dir)?;424433             }425434         }426-        let html = render_release_page(rel, site_name, nav, description);435+        let html = render_release_page(rel, site_name, nav, branch, short_sha, description);427436         let page_dest = out.join("releases").join(format!("{}.html", rel.version));428437         if let Some(parent) = page_dest.parent() {429438             std::fs::create_dir_all(parent)@@ -486,7 +495,17 @@ mod tests {486495         std::fs::create_dir_all(&repo).unwrap();487496         let out = repo.join("out");488497         std::fs::create_dir_all(&out).unwrap();489-        render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");498+        render_loaded(499+            &[rel],500+            &repo,501+            &out,502+            "test",503+            "<nav>",504+            "main",505+            "abc1234",506+            None,507+        )508+        .expect("render");490509         let index =491510             std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");492511         assert!(@@ -535,7 +554,17 @@ mod tests {535554         std::fs::create_dir_all(&repo).unwrap();536555         let out = repo.join("out");537556         std::fs::create_dir_all(&out).unwrap();538-        render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");557+        render_loaded(558+            &[rel],559+            &repo,560+            &out,561+            "test",562+            "<nav>",563+            "main",564+            "abc1234",565+            None,566+        )567+        .expect("render");539568         let index =540569             std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");541570         let tds = index.matches("<td").count();
src/wiki.rs+12 −6
diff --git a/src/wiki.rs b/src/wiki.rsindex 02edd5c..746c229 100644--- a/src/wiki.rs+++ b/src/wiki.rs@@ -15,7 +15,7 @@ use std::collections::BTreeMap;1515 use std::path::Path;1616 1717 use crate::build::markdown_to_html;18-use crate::layout::{esc, page_site};18+use crate::layout::{esc, page_site_with_provenance};1919 2020 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).2121 pub const WIKI_MAX_BYTES: u64 = 256 * 1024;@@ -165,6 +165,8 @@ pub fn render(165165     out: &Path,166166     site_name: &str,167167     nav: &str,168+    branch: &str,169+    short_sha: &str,168170     description: Option<&str>,169171 ) -> Result<Vec<WikiPage>, String> {170172     let root = repo.join(".forge").join("wiki");@@ -290,12 +292,14 @@ pub fn render(290292     }291293     std::fs::write(292294         wiki_dir.join("index.html"),293-        page_site(295+        page_site_with_provenance(294296             &format!("wiki — {site_name}"),295297             nav,296298             &body,297299             site_name,298300             description,301+            branch,302+            short_sha,299303         ),300304     )301305     .map_err(|e| format!("hu13: write wiki index: {e}"))?;@@ -308,12 +312,14 @@ pub fn render(308312             esc(&p.path),309313         );310314         body.push_str(&markdown_to_html(&text));311-        let html = page_site(315+        let html = page_site_with_provenance(312316             &format!("{} — wiki — {site_name}", p.title),313317             nav,314318             &body,315319             site_name,316320             description,321+            branch,322+            short_sha,317323         );318324         let dest = wiki_dir.join(format!("{}.html", p.path));319325         if let Some(parent) = dest.parent() {@@ -383,7 +389,7 @@ mod tests {383389     fn index_titles_sorted_skips_bad() {384390         let repo = fixture_repo();385391         let out = mktemp("out");386-        let pages = render(&repo, &out, "test", "<nav>", None).expect("render");392+        let pages = render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");387393         let paths: Vec<&str> = pages.iter().map(|p| p.path.as_str()).collect();388394         assert_eq!(paths, vec!["docs/nested.md", "hello.md", "plain.md"]);389395         assert_eq!(pages[0].title, "Nested Page");@@ -405,7 +411,7 @@ mod tests {405411     fn pages_render_markdown_escaped() {406412         let repo = fixture_repo();407413         let out = mktemp("out");408-        render(&repo, &out, "test", "<nav>", None).expect("render");414+        render(&repo, &out, "test", "<nav>", "main", "abc1234", None).expect("render");409415         let hello =410416             std::fs::read_to_string(out.join("wiki").join("hello.md.html")).expect("hello page");411417         assert!(hello.contains("<h1>Hello World</h1>"), "markdown rendered");@@ -426,7 +432,7 @@ mod tests {426432     fn missing_wiki_dir_renders_empty_index() {427433         let dir = mktemp("nowiki");428434         let out = mktemp("out");429-        let pages = render(&dir, &out, "test", "<nav>", None).expect("render");435+        let pages = render(&dir, &out, "test", "<nav>", "main", "abc1234", None).expect("render");430436         assert!(pages.is_empty());431437         let index = std::fs::read_to_string(out.join("wiki").join("index.html")).expect("index");432438         assert!(index.contains("No wiki pages yet."));
static/style.css+12 −3
diff --git a/static/style.css b/static/style.cssindex 2efee6d..fa1b70d 100644--- a/static/style.css+++ b/static/style.css@@ -63,9 +63,13 @@ img,video,svg{max-width:100%;height:auto}6363 a{color:var(--blue)}6464 a:visited{color:var(--visited)}6565 main{padding:1.25rem 0 3rem;min-width:0}66-/* Repo header stacks on narrow; clone cmd + sha wrap safely. */67-.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0}68-.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere}66+/* Repo header stacks on narrow; clone cmd + sha wrap safely.67+   Breathing room under the navbar (m6/code-space, f0rge #40): air via68+   CSS margin-top (never <br> spacers); the base stays tight for 360px69+   and wider screens get a touch more. Title is explicit 2rem so it70+   reads as the page title on every viewport. */71+.repo-head{display:flex;flex-direction:column;gap:.25rem;min-width:0;margin-top:.75rem}72+.repo-head h1{margin:0 0 .25rem;overflow-wrap:anywhere;font-size:2rem;line-height:1.2}6973 .clone,.repo-meta{margin:.25rem 0;min-width:0}7074 .clone code,.repo-meta code{overflow-wrap:anywhere;word-break:break-all}7175 code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;@@ -115,7 +119,11 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}115119 .btn-new:visited{color:var(--btn-fg)}116120 .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}117121 .muted{color:var(--muted)}122+footer.site-footer{border-top:1px solid var(--border)}118123 footer.site-footer .wrap{padding:1rem}124+/* Provenance line (m6/footer-line, f0rge #41): the single muted line125+   inside the existing footer block — small, no extra whitespace. */126+footer.site-footer .footer-provenance{margin:.5rem 0;font-size:.85rem}119127 /* Navbar one-bar (m6/navbar, f0rge #18): logo hu13 • scrollable nav • repo.120128    Own classes only (.header-bar/.brand-lockup/.site-logo/.hu13-word/.sep/121129    .nav-scroll/.nav-scroll-row); earlier rules above are untouched. The nav@@ -136,6 +144,7 @@ footer.site-footer .wrap{padding:1rem}136144 @media (min-width:768px){137145   .cols{grid-template-columns:minmax(0,1fr) minmax(0,1.25fr)}138146   .site-nav .wrap{gap:.25rem 1.25rem}147+  .repo-head{margin-top:1.25rem}139148   main{padding:1.5rem 0 3rem}140149 }141150 @media (min-width:1100px){
tests/code_space.rs+184 −0
diff --git a/tests/code_space.rs b/tests/code_space.rsnew file mode 100644index 0000000..7d73a3a--- /dev/null+++ b/tests/code_space.rs@@ -0,0 +1,184 @@1+//! Code-tab breathing room (m6, f0rge issue #40):2+//! the repo-head sits clear of the navbar via CSS margin (never `<br>`3+//! spacers), with a slightly larger title — while staying tight on 360px.4+//!5+//! Builds a small fixture repo with the real binary and string-asserts on6+//! the emitted HTML + `static/style.css` (no rendering engine needed).7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn bin() -> &'static str {12+    env!("CARGO_BIN_EXE_hu13")13+}14+15+fn manifest() -> PathBuf {16+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))17+}18+19+fn fresh(prefix: &str) -> PathBuf {20+    let dir = std::env::temp_dir().join(format!(21+        "hu13-codespace-{prefix}-{}-{}",22+        std::process::id(),23+        std::time::SystemTime::now()24+            .duration_since(std::time::UNIX_EPOCH)25+            .map(|d| d.as_nanos())26+            .unwrap_or(0)27+    ));28+    std::fs::create_dir_all(&dir).unwrap();29+    dir30+}31+32+fn git(repo: &Path, args: &[&str]) {33+    let st = Command::new("git")34+        .arg("-C")35+        .arg(repo)36+        .args(args)37+        .status()38+        .expect("spawn git");39+    assert!(st.success(), "git {args:?} failed");40+}41+42+fn fixture_repo() -> PathBuf {43+    let dir = fresh("repo");44+    git(&dir, &["init", "-b", "main"]);45+    git(&dir, &["config", "user.name", "hu13-test"]);46+    git(&dir, &["config", "user.email", "[email protected]"]);47+    git(&dir, &["config", "commit.gpgsign", "false"]);48+    std::fs::write(dir.join("README.md"), "# space fixture\n\nhello\n").unwrap();49+    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();50+    git(&dir, &["add", "-A"]);51+    git(&dir, &["commit", "-m", "init"]);52+    dir53+}54+55+fn build_fixture() -> PathBuf {56+    let repo = fixture_repo();57+    let out = fresh("out");58+    let st = Command::new(bin())59+        .arg("build")60+        .arg(&repo)61+        .arg("-o")62+        .arg(&out)63+        .status()64+        .expect("spawn hu13 build");65+    assert!(st.success(), "fixture build exits 0");66+    out67+}68+69+fn css() -> String {70+    std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")71+}72+73+/// Value of `prop` in the first `selector{...}` rule inside `scope`.74+fn rule_value(scope: &str, selector: &str, prop: &str) -> Option<String> {75+    let start = scope.find(selector)?;76+    let block_start = scope[start..].find('{')? + start;77+    let block_end = scope[block_start..].find('}')? + block_start;78+    let block = &scope[block_start..block_end];79+    let pos = block.find(prop)?;80+    let after = block[pos + prop.len()..].trim_start().strip_prefix(':')?;81+    let value: String = after82+        .trim_start()83+        .chars()84+        .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t' | '\n'))85+        .collect();86+    Some(value)87+}88+89+fn parse_rem(value: &str) -> Option<f32> {90+    value.strip_suffix("rem")?.trim().parse::<f32>().ok()91+}92+93+#[test]94+fn repo_head_spacing_is_css_margin_not_br_tags() {95+    // Structure assert: the code tab keeps its stacked repo-head96+    // (name + meta + clone block) with air from CSS margin — no `<br>`97+    // spacers between the navbar and the repo-head.98+    let out = build_fixture();99+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");100+    let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");101+    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;102+    let head = &index[head_start..head_end];103+    assert!(head.contains("<h1>"), "repo-head keeps its title");104+    assert!(105+        !head.contains("<br"),106+        "spacing is CSS margin, not <br> tags"107+    );108+    assert!(109+        head.contains("repo-meta"),110+        "branch/sha meta line kept inside repo-head"111+    );112+    // The header bar ends (</header>) before <main> carries the repo-head:113+    // the break between nav and repo-head is a block boundary + margin.114+    let header_end = index.find("</header>").expect("header close");115+    assert!(116+        header_end < head_start,117+        "repo-head sits below the navbar block"118+    );119+    let between = &index[header_end..head_start];120+    assert!(121+        !between.contains("<br"),122+        "no spacer tags between navbar and repo-head"123+    );124+125+    // CSS assert: `.repo-head` carries the top margin.126+    let css = css();127+    let base = css.split("@media (min-width").next().expect("base css");128+    let margin = rule_value(base, ".repo-head", "margin-top").expect("repo-head margin-top");129+    assert!(130+        parse_rem(&margin).is_some_and(|v| v > 0.0),131+        "repo-head margin-top is positive breathing room, got {margin:?}"132+    );133+}134+135+#[test]136+fn repo_head_title_reads_as_page_title() {137+    // Slightly larger title treatment: explicit size on the repo-head h1.138+    let css = css();139+    let base = css.split("@media (min-width").next().expect("base css");140+    let size = rule_value(base, ".repo-head h1", "font-size").expect("repo-head h1 font-size");141+    let rem = parse_rem(&size).expect("h1 size in rem");142+    assert!(143+        rem >= 1.5,144+        "repo-head title is title-scale (>=1.5rem), got {size:?}"145+    );146+    let out = build_fixture();147+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");148+    assert!(149+        index.contains("<div class=\"repo-head\"><h1>"),150+        "repo-head h1 opens the code tab body"151+    );152+}153+154+#[test]155+fn mobile_stays_tight_wider_screens_get_more() {156+    // 360px must not drown in whitespace: the base (pre-media-query)157+    // margin stays modest; >=768px may open up a touch more.158+    let css = css();159+    let base = css.split("@media (min-width").next().expect("base css");160+    let base_margin =161+        rule_value(base, ".repo-head", "margin-top").expect("base repo-head margin-top");162+    let base_rem = parse_rem(&base_margin).expect("base margin in rem");163+    assert!(164+        base_rem <= 1.0,165+        "mobile margin-top stays tight (<=1rem), got {base_margin:?}"166+    );167+    let desktop = css168+        .split("@media (min-width:768px)")169+        .nth(1)170+        .expect("768px breakpoint");171+    // Only the current breakpoint block: cut before the next @media.172+    let block = desktop.split("@media").next().expect("768 block");173+    let wide_margin =174+        rule_value(block, ".repo-head", "margin-top").expect("wide repo-head margin-top");175+    let wide_rem = parse_rem(&wide_margin).expect("wide margin in rem");176+    assert!(177+        wide_rem >= base_rem,178+        "wider screens keep at least base air ({wide_margin:?} vs {base_margin:?})"179+    );180+    assert!(181+        wide_rem <= 2.0,182+        "even wide stays modest (<=2rem), got {wide_margin:?}"183+    );184+}
tests/provenance.rs+161 −0
diff --git a/tests/provenance.rs b/tests/provenance.rsnew file mode 100644index 0000000..dff7e55--- /dev/null+++ b/tests/provenance.rs@@ -0,0 +1,161 @@1+//! Footer provenance line (m6, f0rge issue #41):2+//! every page extends the existing footer block with a single muted line3+//! (`generated from a snapshot of a repo and its f0rge` + branch@sha).4+//!5+//! Builds a small fixture repo with the real binary and string-asserts on6+//! the emitted HTML + `static/style.css` (no rendering engine needed).7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn bin() -> &'static str {12+    env!("CARGO_BIN_EXE_hu13")13+}14+15+fn manifest() -> PathBuf {16+    PathBuf::from(env!("CARGO_MANIFEST_DIR"))17+}18+19+fn fresh(prefix: &str) -> PathBuf {20+    let dir = std::env::temp_dir().join(format!(21+        "hu13-provenance-{prefix}-{}-{}",22+        std::process::id(),23+        std::time::SystemTime::now()24+            .duration_since(std::time::UNIX_EPOCH)25+            .map(|d| d.as_nanos())26+            .unwrap_or(0)27+    ));28+    std::fs::create_dir_all(&dir).unwrap();29+    dir30+}31+32+fn git(repo: &Path, args: &[&str]) {33+    let st = Command::new("git")34+        .arg("-C")35+        .arg(repo)36+        .args(args)37+        .status()38+        .expect("spawn git");39+    assert!(st.success(), "git {args:?} failed");40+}41+42+fn git_out(repo: &Path, args: &[&str]) -> String {43+    let out = Command::new("git")44+        .arg("-C")45+        .arg(repo)46+        .args(args)47+        .output()48+        .expect("spawn git");49+    assert!(out.status.success(), "git {args:?} failed");50+    String::from_utf8_lossy(&out.stdout).trim().to_string()51+}52+53+/// Fixture with two commits so list + detail pages exist.54+fn fixture_repo() -> PathBuf {55+    let dir = fresh("repo");56+    git(&dir, &["init", "-b", "main"]);57+    git(&dir, &["config", "user.name", "hu13-test"]);58+    git(&dir, &["config", "user.email", "[email protected]"]);59+    git(&dir, &["config", "commit.gpgsign", "false"]);60+    std::fs::write(dir.join("README.md"), "# provenance fixture\n\nhello\n").unwrap();61+    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();62+    git(&dir, &["add", "-A"]);63+    git(&dir, &["commit", "-m", "first"]);64+    std::fs::write(dir.join("hello.txt"), "hello again\n").unwrap();65+    git(&dir, &["add", "-A"]);66+    git(&dir, &["commit", "-m", "second"]);67+    dir68+}69+70+fn footer_of(html: &str) -> &str {71+    let start = html.find("<footer").expect("footer open");72+    let end = html.find("</footer>").expect("footer close") + "</footer>".len();73+    &html[start..end]74+}75+76+#[test]77+fn footer_provenance_on_index_and_detail_pages() {78+    let repo = fixture_repo();79+    let sha = git_out(&repo, &["rev-parse", "--short", "HEAD"]);80+    assert!(!sha.is_empty(), "fixture has a short sha");81+    let out = fresh("out");82+    let st = Command::new(bin())83+        .arg("build")84+        .arg(&repo)85+        .arg("-o")86+        .arg(&out)87+        .status()88+        .expect("spawn hu13 build");89+    assert!(st.success(), "fixture build exits 0");90+91+    // One commit-detail page exists.92+    let commit_page = std::fs::read_dir(out.join("commit"))93+        .expect("commit dir")94+        .filter_map(|e| e.ok())95+        .map(|e| e.path())96+        .find(|p| p.extension().is_some_and(|x| x == "html"))97+        .expect("one commit page");98+99+    // Index + a detail page (commit detail) + breadth across tabs.100+    let mut pages = vec![101+        out.join("index.html"),102+        commit_page,103+        out.join("commits.html"),104+        out.join("files").join("hello.txt.html"),105+        out.join("issues/index.html"),106+        out.join("agents.html"),107+        out.join("new-issue.html"),108+    ];109+    // Wiki/releases tabs render even when empty (deep links keep working).110+    pages.push(out.join("wiki/index.html"));111+    pages.push(out.join("releases/index.html"));112+    for page in &pages {113+        let html =114+            std::fs::read_to_string(page).unwrap_or_else(|_| panic!("read {}", page.display()));115+        assert_eq!(116+            html.matches("<footer").count(),117+            1,118+            "{} extends the existing footer (exactly one)",119+            page.display()120+        );121+        assert!(122+            html.contains("<footer class=\"site-footer\">"),123+            "{} keeps the footer block",124+            page.display()125+        );126+        assert!(127+            html.contains("generated from a snapshot of a repo and its f0rge"),128+            "{} carries the provenance line",129+            page.display()130+        );131+        assert!(132+            html.contains(&format!("main @ {sha}")),133+            "{} names the built snapshot",134+            page.display()135+        );136+        // Muted, single line: no spacers inside the footer.137+        let footer = footer_of(&html);138+        assert!(139+            footer.contains("muted"),140+            "{} footer is muted",141+            page.display()142+        );143+        assert!(144+            !footer.contains("<br"),145+            "{} footer is a single line",146+            page.display()147+        );148+    }149+}150+151+#[test]152+fn footer_styling_is_muted_and_tiny() {153+    let css = std::fs::read_to_string(manifest().join("static").join("style.css"))154+        .expect("read style.css");155+    assert!(css.contains("footer.site-footer"), "footer block styled");156+    assert!(157+        css.contains(".footer-provenance"),158+        "provenance line has its own rule"159+    );160+    assert!(css.contains(".muted"), "muted tone reused, not a new color");161+}