diff --git a/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diffnew file mode 100644index 0000000..c2fcdf4--- /dev/null+++ b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff@@ -0,0 +1,480 @@1+From 92901ce02836be1a28ffa9541a3ba937e2a87826 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 16:24:47 +00004+Subject: [PATCH] diffs: per-file cards, dual line numbers, visual diffstat5+ (#74)6+MIME-Version: 1.07+Content-Type: text/plain; charset=UTF-88+Content-Transfer-Encoding: 8bit9+10+diff_html now parses chunks into one <details> card per11+file: path, +n/−m counts, proportional green/red bar, and every hunk12+line in a two-column (old/new) gutter. /index/mode/Binary13+lines stay visible as dim metadata; --- /+++ use --diff-file (now14+purple, distinct from body text); add/del rows carry the existing15+background colors. Format-patch mbox preambles (From/Date/Subject/16+stat) are dropped because they duplicate the PR title. Non-unified17+text still falls back to the old single-block coloring.18+19+Commit pages drop 'Author:/Date:' for a chip + monogram + relative20+time (parse_ts_secs now accepts git's 'YYYY-MM-DD HH:MM:SS ±HHMM'),21+and the commits table shows relative dates with exact values in22+title.23+---24+ src/build.rs | 35 +++++++-25+ src/commits.rs | 56 ++++++++++--26+ src/layout.rs | 187 ++++++++++++++++++++++++++++++++--------27+ static/style.css | 24 +++++-28+ tests/theme_contrast.rs | 2 +29+ 5 files changed, 257 insertions(+), 47 deletions(-)30+31+diff --git a/src/build.rs b/src/build.rs32+index 4c00c85..a813a50 10064433+--- a/src/build.rs34++++ b/src/build.rs35+@@ -522,7 +522,12 @@ pub fn now_unix() -> i64 {36+ #[must_use]37+ pub fn parse_ts_secs(s: &str) -> Option<i64> {38+ let s = s.trim();39+- let (date, rest) = s.split_once('T')?;40++ // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's41++ // `2026-10-27 14:08:48 +0000` / `+00:00` forms.42++ let (date, rest) = match s.split_once('T') {43++ Some(v) => v,44++ None => s.split_once(' ')?,45++ };46+ let mut d = date.split('-');47+ let y: i64 = d.next()?.parse().ok()?;48+ let mo: i64 = d.next()?.parse().ok()?;49+@@ -532,7 +537,10 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {50+ }51+ let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {52+ (t, 0i64)53+- } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {54++ } else if rest.len() >= 655++ && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')56++ && rest.as_bytes()[rest.len() - 3] == b':'57++ {58+ let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {59+ -160+ } else {61+@@ -541,6 +549,15 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {62+ let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;63+ let om: i64 = rest[rest.len() - 2..].parse().ok()?;64+ (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))65++ } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {66++ let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {67++ -168++ } else {69++ 170++ };71++ let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;72++ let om: i64 = rest[rest.len() - 2..].parse().ok()?;73++ (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))74+ } else {75+ (rest, 0)76+ };77+@@ -553,6 +570,7 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {78+ .split('.')79+ .next()80+ .unwrap_or("0")81++ .trim()82+ .parse()83+ .ok()?;84+ if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {85+@@ -2189,6 +2207,19 @@ mod tests {86+ assert!(got.ends_with("…"));87+ }88+ 89++ #[test]90++ fn parse_git_and_rfc3339_dates() {91++ assert_eq!(92++ parse_ts_secs("2026-09-10T07:43:55Z"),93++ parse_ts_secs("2026-09-10 07:43:55 +0000")94++ );95++ let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();96++ let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();97++ assert_eq!(utc, plus1, "offset is applied");98++ assert!(parse_ts_secs("not a date").is_none());99++ assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");100++ }101++102+ #[test]103+ fn relative_age_fixed_clock() {104+ // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.105+diff --git a/src/commits.rs b/src/commits.rs106+index 67872bd..9cf0b82 100644107+--- a/src/commits.rs108++++ b/src/commits.rs109+@@ -8,9 +8,42 @@110+ 111+ use std::path::Path;112+ 113++use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title};114+ use crate::gitx;115+ use crate::layout::{diff_html, esc, page_site_with_provenance};116+ 117++/// Deterministic 1-letter author monogram (same rule as forge pages).118++fn monogram(handle: &str) -> String {119++ let h = handle.trim();120++ let initial: String = h121++ .chars()122++ .next()123++ .map(|c| c.to_uppercase().to_string())124++ .unwrap_or_default();125++ let mut hash: u32 = 0;126++ for b in h.bytes() {127++ hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));128++ }129++ format!(130++ "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",131++ hash % 6,132++ esc(&initial)133++ )134++}135++136++/// Relative commit time with the exact value in `title` (f0rge #72/#74).137++fn time_ago(date: &str, now: i64) -> String {138++ match parse_ts_secs(date) {139++ Some(ts) => format!(140++ "<time datetime=\"{}\" title=\"{}\">{}</time>",141++ esc(date),142++ esc(&ts_title(date)),143++ esc(&relative_age(now, ts))144++ ),145++ None => format!("<time>{}</time>", esc(date)),146++ }147++}148++149+ /// Short display form of a full sha (first 7 hex chars, git-style).150+ #[must_use]151+ pub fn short_sha(sha: &str) -> &str {152+@@ -40,19 +73,21 @@ pub fn render_loaded(153+ if log.is_empty() {154+ body.push_str("<p class=\"muted\">No commits yet.</p>\n");155+ } else {156++ let now = now_unix();157+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");158+ for c in log {159+ body.push_str(&format!(160+- "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",161++ "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",162+ esc(&c.subject),163+ esc(&c.subject),164+ esc(&c.sha),165+ esc(&c.sha),166+ esc(short_sha(&c.sha)),167+ esc(&c.author),168++ monogram(&c.author),169+ esc(&c.author),170+ esc(&c.date),171+- esc(&c.date),172++ time_ago(&c.date, now),173+ ));174+ }175+ body.push_str("</tbody>\n</table></div>\n");176+@@ -75,14 +110,17 @@ pub fn render_loaded(177+ for c in log {178+ let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;179+ let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;180++ let now = now_unix();181+ let detail = format!(182+- "<h1>commit {}</h1>\n<p class=\"repo-meta\"><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{}</pre></div>\n<h2>Diff</h2>\n<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",183+- esc(short_sha(&c.sha)),184+- esc(&c.sha),185+- esc(&c.author),186+- esc(&c.date),187+- esc(&message),188+- diff_html(&diff),189++ "<h1>{subject}</h1>\n<div class=\"meta\"><span class=\"chip\"><a href=\"/commit/{full}.html\"><code>{short}</code></a></span>{mono}<span class=\"who\">{author}</span><span class=\"meta-when\">{when}</span></div>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{message}</pre></div>\n<h2>Diff</h2>\n{diff}\n",190++ subject = esc(&c.subject),191++ full = esc(&c.sha),192++ short = esc(short_sha(&c.sha)),193++ mono = monogram(&c.author),194++ author = esc(&c.author),195++ when = time_ago(&c.date, now),196++ message = esc(&message),197++ diff = diff_html(&diff),198+ );199+ let html = page_site_with_provenance(200+ &format!("commit {} — {site_name}", short_sha(&c.sha)),201+diff --git a/src/layout.rs b/src/layout.rs202+index d985a04..20480b7 100644203+--- a/src/layout.rs204++++ b/src/layout.rs205+@@ -24,26 +24,149 @@ pub fn esc(input: &str) -> String {206+ out207+ }208+ 209+-/// Render unified diff / patch text as escaped HTML with line-level color210+-/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in211+-/// `static/style.css`, dark + light).212++/// Render unified diff text as one card per file (f0rge #74).213+ ///214+-/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in215+-/// `<span class="diff-hunk">`, `+++`/`---` file lines in216+-/// `<span class="diff-file">` (bold fg), `+` additions in217+-/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.218+-/// Everything else (context, `diff --git` / `index` headers, commit219+-/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /220+-/// binary markers) is escaped as-is with no span so truncation and binary221+-/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,222+-/// so hostile `<script>` inside an added line renders as text. Output joins223+-/// lines with `\n` (preserving a trailing newline) for use inside an224+-/// already-escaped `<pre class="diff">`.225++/// Each card carries the path, `+n −m` counts and a proportional bar;226++/// hunk lines show old/new line numbers in a two-column gutter. `diff227++/// --git`, `index`, mode and `Binary files` lines stay visible as dim228++/// metadata; the format-patch mbox preamble (From/Date/Subject/stat) is229++/// dropped because it duplicates the PR title. Every byte is escaped230++/// before wrapping, so a hostile `<script>` in an added line is text.231+ #[must_use]232+ pub fn diff_html(raw: &str) -> String {233+ if raw.is_empty() {234+ return String::new();235+ }236++ if !raw.lines().any(|l| l.starts_with("diff --git ")) {237++ return plain_diff_html(raw);238++ }239++ let all: Vec<&str> = raw.lines().collect();240++ let mut starts: Vec<usize> = all241++ .iter()242++ .enumerate()243++ .filter(|(_, l)| l.starts_with("diff --git "))244++ .map(|(i, _)| i)245++ .collect();246++ if starts.is_empty() {247++ return plain_diff_html(raw);248++ }249++ starts.push(all.len());250++ let mut out = String::with_capacity(raw.len() + raw.len() / 3);251++ for w in starts.windows(2) {252++ let chunk = &all[w[0]..w[1]];253++ let path = diff_chunk_path(chunk);254++ let mut added = 0usize;255++ let mut removed = 0usize;256++ for line in chunk {257++ if line.starts_with('+') && !line.starts_with("+++") {258++ added += 1;259++ } else if line.starts_with('-') && !line.starts_with("---") {260++ removed += 1;261++ }262++ }263++ let total = added + removed;264++ let pct = (added * 100).checked_div(total).unwrap_or(0);265++ out.push_str("<details open class=\"difffile\"><summary><code class=\"df-path\">");266++ out.push_str(&esc(&path));267++ out.push_str("</code><span class=\"df-count\"><span class=\"df-add\">+");268++ out.push_str(&added.to_string());269++ out.push_str("</span> <span class=\"df-del\">−");270++ out.push_str(&removed.to_string());271++ out.push_str("</span></span>");272++ if total > 0 {273++ out.push_str("<span class=\"df-bar\" aria-hidden=\"true\"><span class=\"df-bar-add\" style=\"width:");274++ out.push_str(&pct.to_string());275++ out.push_str("%\"></span></span>");276++ }277++ out.push_str("</summary>\n<div class=\"pre-scroll\"><pre class=\"diff\">");278++279++ let mut old_ln = 0i64;280++ let mut new_ln = 0i64;281++ let mut first = true;282++ for line in chunk {283++ if !first {284++ out.push('\n');285++ }286++ first = false;287++ if line.starts_with("@@") {288++ let mut parts = line.split_whitespace();289++ let _ = parts.next();290++ if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {291++ old_ln = o292++ .split(',')293++ .next()294++ .and_then(|v| v.parse().ok())295++ .unwrap_or(0);296++ }297++ if let Some(n) = parts.next().and_then(|v| v.strip_prefix('+')) {298++ new_ln = n299++ .split(',')300++ .next()301++ .and_then(|v| v.parse().ok())302++ .unwrap_or(0);303++ }304++ out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));305++ } else if line.starts_with('+') && !line.starts_with("+++") {306++ out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));307++ new_ln += 1;308++ } else if line.starts_with('-') && !line.starts_with("---") {309++ out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));310++ old_ln += 1;311++ } else if line.starts_with(' ') {312++ out.push_str(&diff_numbered(313++ &old_ln.to_string(),314++ &new_ln.to_string(),315++ "dctx",316++ line,317++ ));318++ old_ln += 1;319++ new_ln += 1;320++ } else if line.starts_with("+++") || line.starts_with("---") {321++ out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));322++ } else {323++ out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));324++ }325++ }326++ out.push_str("</pre></div></details>\n");327++ }328++ out329++}330++331++fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {332++ format!(333++ "<span class=\"df-line {class}-row\"><span class=\"dln\">{old}</span><span class=\"dln\">{new}</span><span class=\"{class}\">{}</span></span>",334++ esc(line)335++ )336++}337++338++/// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the339++/// `diff --git` header. `/dev/null` falls through to the other side.340++fn diff_chunk_path(lines: &[&str]) -> String {341++ for line in lines {342++ if let Some(rest) = line.strip_prefix("+++ ") {343++ if rest != "/dev/null" {344++ return rest.strip_prefix("b/").unwrap_or(rest).to_string();345++ }346++ }347++ }348++ for line in lines {349++ if let Some(rest) = line.strip_prefix("--- ") {350++ if rest != "/dev/null" {351++ return rest.strip_prefix("a/").unwrap_or(rest).to_string();352++ }353++ }354++ }355++ lines356++ .first()357++ .and_then(|l| l.strip_prefix("diff --git a/"))358++ .and_then(|rest| rest.split(" b/").next())359++ .unwrap_or("diff")360++ .to_string()361++}362++363++/// Fallback for diff text without `diff --git` headers: one escaped block364++/// with line-level coloring (markers stay span-free, old behavior).365++fn plain_diff_html(raw: &str) -> String {366+ let mut out = String::with_capacity(raw.len() + raw.len() / 4);367+ let mut first = true;368+ for line in raw.lines() {369+@@ -51,26 +174,7 @@ pub fn diff_html(raw: &str) -> String {370+ out.push('\n');371+ }372+ first = false;373+- let e = esc(line);374+- if line.starts_with("@@") {375+- out.push_str("<span class=\"diff-hunk\">");376+- out.push_str(&e);377+- out.push_str("</span>");378+- } else if line.starts_with("+++") || line.starts_with("---") {379+- out.push_str("<span class=\"diff-file\">");380+- out.push_str(&e);381+- out.push_str("</span>");382+- } else if line.starts_with('+') {383+- out.push_str("<span class=\"diff-add\">");384+- out.push_str(&e);385+- out.push_str("</span>");386+- } else if line.starts_with('-') {387+- out.push_str("<span class=\"diff-del\">");388+- out.push_str(&e);389+- out.push_str("</span>");390+- } else {391+- out.push_str(&e);392+- }393++ out.push_str(&diff_line_span(line));394+ }395+ if raw.ends_with('\n') {396+ out.push('\n');397+@@ -78,6 +182,21 @@ pub fn diff_html(raw: &str) -> String {398+ out399+ }400+ 401++fn diff_line_span(line: &str) -> String {402++ let e = esc(line);403++ if line.starts_with("@@") {404++ format!("<span class=\"diff-hunk\">{e}</span>")405++ } else if line.starts_with("+++") || line.starts_with("---") {406++ format!("<span class=\"diff-file\">{e}</span>")407++ } else if line.starts_with('+') {408++ format!("<span class=\"diff-add\">{e}</span>")409++ } else if line.starts_with('-') {410++ format!("<span class=\"diff-del\">{e}</span>")411++ } else {412++ e413++ }414++}415++416+ /// Render a full HTML page. `title` is escaped; `nav` and `body` are417+ /// inlined as-is (caller must escape untrusted text with [`esc`]).418+ #[allow(dead_code)]419+diff --git a/static/style.css b/static/style.css420+index 53ab719..ff5d619 100644421+--- a/static/style.css422++++ b/static/style.css423+@@ -48,7 +48,7 @@424+ --diff-del:#f7768e;425+ --diff-del-bg:#38232e;426+ --diff-hunk:#7dcfff;427+- --diff-file:#c0caf5;428++ --diff-file:#bb9af7;429+ }430+ @media (prefers-color-scheme:light){431+ :root{432+@@ -82,7 +82,7 @@433+ --diff-del:#8c4351;434+ --diff-del-bg:#d9c2c7;435+ --diff-hunk:#0f4b6e;436+- --diff-file:#343b58;437++ --diff-file:#5a4a78;438+ }439+ }440+ html{background:var(--bg);-webkit-text-size-adjust:100%}441+@@ -347,3 +347,23 @@ details.clonebox:not([open]){padding-bottom:.25rem}442+ .comment-head{display:flex;align-items:center;color:var(--fg-dim);font-size:var(--fs-sm);margin:0 0 var(--s2)}443+ .comment-head .who{color:var(--fg);font-weight:600;margin-right:.35em}444+ time{font-variant-numeric:tabular-nums}445++446++/* Diff cards (f0rge #74): one card per file, dual line numbers. */447++.difffile{border:1px solid var(--border);background:var(--surface);margin:var(--s3) 0}448++.difffile>summary{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2) var(--s3);padding:var(--s2) var(--s3);cursor:pointer;background:var(--bg-alt)}449++.difffile>summary::marker{color:var(--fg-dim)}450++.df-path{color:var(--fg);background:none;padding:0;overflow-wrap:anywhere}451++.df-count{font-size:var(--fs-sm);font-variant-numeric:tabular-nums;margin-left:auto}452++.df-add{color:var(--green)}453++.df-del{color:var(--red)}454++.df-bar{display:inline-block;width:6rem;height:.5rem;background:var(--diff-del-bg);border:1px solid var(--border)}455++.df-bar-add{display:block;height:100%;background:var(--green)}456++.difffile .pre-scroll{margin:0}457++pre.diff{margin:0;border:0;background:var(--surface)}458++.df-line{display:block}459++.dln{display:inline-block;width:2.8em;text-align:right;padding-right:.5em;color:var(--fg-dim);user-select:none;font-variant-numeric:tabular-nums}460++.dln+.dln{border-right:1px solid var(--border);margin-right:.5em}461++.diff-add-row{background:var(--diff-add-bg)}462++.diff-del-row{background:var(--diff-del-bg)}463++.diff-file{color:var(--diff-file);font-weight:600}464++.df-meta{color:var(--fg-dim)}465+diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rs466+index 9ce311f..0c0fea3 100644467+--- a/tests/theme_contrast.rs468++++ b/tests/theme_contrast.rs469+@@ -117,6 +117,8 @@ fn both_themes_pass_aa_for_text_colors() {470+ ("--tok-com", "--surface-2"),471+ ("--tok-fn", "--surface"),472+ ("--tok-type", "--surface"),473++ ("--diff-file", "--surface"),474++ ("--diff-hunk", "--surface"),475+ ];476+ check("dark", dark, &pairs);477+ check("light", light, &pairs);478+-- 479+2.56.0480+