.forge/prs/b7b3fac0-57b2-4497-9a52-e0fef1a46542/patch.diff 48.9 KiB 1394 lines Raw
1 From 985dba6a02dd44508ee4898852e2fc14a490686a 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