record #72 merge

6f54110gl0w

all commits

Message

record #72 merge

Diff

.forge/merges/2026-10-27T16-11-29.027971502Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T16-11-29.027971502Z.md b/.forge/merges/2026-10-27T16-11-29.027971502Z.mdnew file mode 100644index 0000000..bc17d25--- /dev/null+++ b/.forge/merges/2026-10-27T16-11-29.027971502Z.md@@ -0,0 +1,9 @@1+---2+pr: 86d9d56e-9d80-4941-8be1-e33f182d475d3+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T16:11:29.027971502Z7+---8+clean apply9+patch: 59edf73dd710b0bf9ef8c925a7562e337291b145
.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/patch.diff+550 −0
diff --git a/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/patch.diff b/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/patch.diffnew file mode 100644index 0000000..0542d4b--- /dev/null+++ b/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/patch.diff@@ -0,0 +1,550 @@1+From 1be028bc7980ac458cf9efcb0c4e4763b23c072a Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 16:10:38 +00004+Subject: [PATCH] meta pass: pills, chips, monograms, relative dates (#72)5+MIME-Version: 1.06+Content-Type: text/plain; charset=UTF-87+Content-Transfer-Encoding: 8bit8+9+Issue/PR/CI pages no longer open with a six-line 'state: / labels: /10+created: 2026-…769090401Z / id: <uuid>' dump. They start with a11+state pill (open/merged/closed), label + milestone chips, a CI badge12+where relevant, and 'opened 3d ago'; everything else (assignees,13+updated, branches, linked issue ids, run id) moves into a details14+block. Times are <time datetime=ISO title=exact UTC> with a relative15+label; parse_ts_secs/ts_title are hand-rolled (no dep) and tested.16+17+Comments and reviews get a deterministic-hue monogram + handle +18+relative time. Index tables use pills and relative created values.19+CI step table is 'step | exit | duration' with '197 ms'/'1.2 s'20+instead of raw duration_ms. Release pages show relative created.21+---22+ src/build.rs     |  82 ++++++++++++++++++23+ src/forge.rs     | 212 ++++++++++++++++++++++++++++++++---------------24+ src/releases.rs  |  20 ++++-25+ static/style.css |  25 ++++++26+ 4 files changed, 271 insertions(+), 68 deletions(-)27+28+diff --git a/src/build.rs b/src/build.rs29+index 5642740..13e79e0 10064430+--- a/src/build.rs31++++ b/src/build.rs32+@@ -514,6 +514,88 @@ pub fn now_unix() -> i64 {33+         .unwrap_or(0)34+ }35+ 36++/// Parse an RFC3339 timestamp to unix seconds (fractional seconds and37++/// numeric offsets accepted; `Z` = UTC). Hand-rolled: no date dependency.38++///39++/// Returns `None` for anything that is not a real timestamp, so callers40++/// can fall back to showing the raw string.41++#[must_use]42++pub fn parse_ts_secs(s: &str) -> Option<i64> {43++    let s = s.trim();44++    let (date, rest) = s.split_once('T')?;45++    let mut d = date.split('-');46++    let y: i64 = d.next()?.parse().ok()?;47++    let mo: i64 = d.next()?.parse().ok()?;48++    let da: i64 = d.next()?.parse().ok()?;49++    if !(1..=12).contains(&mo) || !(1..=31).contains(&da) {50++        return None;51++    }52++    let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {53++        (t, 0i64)54++    } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {55++        let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {56++            -157++        } else {58++            159++        };60++        let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;61++        let om: i64 = rest[rest.len() - 2..].parse().ok()?;62++        (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))63++    } else {64++        (rest, 0)65++    };66++    let mut t = hms.split(':');67++    let h: i64 = t.next()?.parse().ok()?;68++    let mi: i64 = t.next()?.parse().ok()?;69++    let sec: i64 = t70++        .next()71++        .unwrap_or("0")72++        .split('.')73++        .next()74++        .unwrap_or("0")75++        .parse()76++        .ok()?;77++    if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {78++        return None;79++    }80++    // Howard Hinnant's days_from_civil.81++    let y2 = if mo <= 2 { y - 1 } else { y };82++    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;83++    let yoe = y2 - era * 400;84++    let mp = (mo + 9) % 12;85++    let doy = (153 * mp + 2) / 5 + da - 1;86++    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;87++    let days = era * 146_097 + doe - 719_468;88++    Some(days * 86_400 + h * 3600 + mi * 60 + sec - offset)89++}90++91++/// Short human title for a timestamp: `2026-09-10 03:52 UTC`92++/// (falls back to the input when it does not parse).93++#[must_use]94++pub fn ts_title(s: &str) -> String {95++    let Some(secs) = parse_ts_secs(s) else {96++        return s.to_string();97++    };98++    let days = secs.div_euclid(86_400);99++    let rem = secs.rem_euclid(86_400);100++    // Civil-from-days (inverse of days_from_civil).101++    let z = days + 719_468;102++    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;103++    let doe = z - era * 146_097;104++    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;105++    let y = yoe + era * 400;106++    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);107++    let mp = (5 * doy + 2) / 153;108++    let d = doy - (153 * mp + 2) / 5 + 1;109++    let m = if mp < 10 { mp + 3 } else { mp - 9 };110++    let y = if m <= 2 { y + 1 } else { y };111++    format!(112++        "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",113++        rem / 3600,114++        (rem % 3600) / 60115++    )116++}117++118+ /// Build-time relative age (`3d ago` style, Gitea `relative-time`119+ /// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds;120+ /// future timestamps clamp to `just now`. Thresholds: ≤10s just now,121+diff --git a/src/forge.rs b/src/forge.rs122+index dda2cf3..ba988e7 100644123+--- a/src/forge.rs124++++ b/src/forge.rs125+@@ -867,21 +867,75 @@ pub fn verdict_badge(verdict: &str) -> String {126+     }127+ }128+ 129+-fn meta_row(label: &str, value: &str) -> String {130++/// CSS class for a state pill (open/closed/merged/other).131++fn state_class(state: &str) -> &'static str {132++    match state.trim().to_ascii_lowercase().as_str() {133++        "open" => "open",134++        "closed" => "closed",135++        "merged" => "merged",136++        _ => "other",137++    }138++}139++140++/// Colored state pill (f0rge #72).141++fn state_pill(state: &str) -> String {142+     format!(143+-        "<p><span class=\"muted\">{}</span> {}</p>\n",144+-        esc(label),145+-        value146++        "<span class=\"pill state-{}\">{}</span>",147++        state_class(state),148++        esc(state.trim())149+     )150+ }151+ 152+-fn render_comment(c: &Comment) -> String {153++/// 1-letter author monogram with a deterministic hue class.154++fn monogram(handle: &str) -> String {155++    let h = handle.trim();156++    let initial: String = h157++        .chars()158++        .next()159++        .map(|c| c.to_uppercase().to_string())160++        .unwrap_or_default();161++    let mut hash: u32 = 0;162++    for b in h.bytes() {163++        hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));164++    }165++    format!(166++        "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",167++        hash % 6,168++        esc(&initial)169++    )170++}171++172++/// `<time>` element with a relative label and the exact UTC value in173++/// `title`/`datetime` (never a raw nanosecond ISO string in visible text).174++fn time_ago(s: &str, now: i64) -> String {175++    match crate::build::parse_ts_secs(s) {176++        Some(ts) => format!(177++            "<time datetime=\"{}\" title=\"{}\">{}</time>",178++            esc(s),179++            esc(&crate::build::ts_title(s)),180++            esc(&crate::build::relative_age(now, ts))181++        ),182++        None => format!("<time>{}</time>", esc(s)),183++    }184++}185++186++/// Human duration for CI steps: `7 ms` under a second, else `1.2 s`.187++fn fmt_duration(ms: u64) -> String {188++    if ms < 1000 {189++        format!("{ms} ms")190++    } else {191++        format!("{:.1} s", ms as f64 / 1000.0)192++    }193++}194++195++fn render_comment(c: &Comment, now: i64) -> String {196+     let mut out = String::from("<li class=\"comment\">\n");197+     let author = c.author.as_deref().unwrap_or("unknown");198+     out.push_str(&format!(199+-        "<p class=\"muted\">{} · <code>{}</code></p>\n",200+-        esc(&c.created),201++        "<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {}</p>\n",202++        monogram(author),203+         esc(author),204++        time_ago(&c.created, now),205+     ));206+     out.push_str(&markdown_to_html(&c.body));207+     out.push_str("</li>\n");208+@@ -901,36 +955,39 @@ fn render_issue_page(209+         n = issue.number,210+         title = esc(&issue.title),211+     );212+-    body.push_str(&meta_row("state:", &esc(&issue.state)));213+-    if !issue.labels.is_empty() {214+-        let labels = issue215+-            .labels216+-            .iter()217+-            .map(|l| esc(l))218+-            .collect::<Vec<_>>()219+-            .join(", ");220+-        body.push_str(&meta_row("labels:", &labels));221+-    }222+-    if !issue.assignees.is_empty() {223+-        let assignees = issue224+-            .assignees225+-            .iter()226+-            .map(|a| esc(a))227+-            .collect::<Vec<_>>()228+-            .join(", ");229+-        body.push_str(&meta_row("assignees:", &assignees));230++    let now = crate::build::now_unix();231++    body.push_str("<div class=\"meta\">");232++    body.push_str(&state_pill(&issue.state));233++    for l in &issue.labels {234++        body.push_str(&format!("<span class=\"chip\">{}</span>", esc(l)));235+     }236+     if let Some(m) = &issue.milestone {237+-        body.push_str(&meta_row("milestone:", &esc(m)));238++        body.push_str(&format!("<span class=\"chip milestone\">{}</span>", esc(m)));239+     }240+-    body.push_str(&meta_row("created:", &esc(&issue.created)));241+-    if let Some(u) = &issue.updated {242+-        body.push_str(&meta_row("updated:", &esc(u)));243+-    }244+-    body.push_str(&meta_row(245+-        "id:",246+-        &format!("<code>{}</code>", esc(&issue.id)),247++    body.push_str(&format!(248++        "<span class=\"meta-when\">opened {}</span>",249++        time_ago(&issue.created, now)250+     ));251++    body.push_str("</div>\n");252++    let mut details =253++        String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");254++    if !issue.assignees.is_empty() {255++        let who = issue256++            .assignees257++            .iter()258++            .map(|a| format!("{}{}", monogram(a), esc(a)))259++            .collect::<Vec<_>>()260++            .join(", ");261++        details.push_str(&format!("<dt>assignees</dt><dd>{who}</dd>\n"));262++    }263++    if let Some(u) = &issue.updated {264++        details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));265++    }266++    details.push_str(&format!(267++        "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",268++        esc(&issue.id)269++    ));270++    body.push_str(&details);271+     body.push_str(&markdown_to_html(&issue.body));272+     body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));273+     if issue.comments.is_empty() {274+@@ -938,7 +995,7 @@ fn render_issue_page(275+     } else {276+         body.push_str("<ul class=\"comments\">\n");277+         for c in &issue.comments {278+-            body.push_str(&render_comment(c));279++            body.push_str(&render_comment(c, now));280+         }281+         body.push_str("</ul>\n");282+     }283+@@ -967,21 +1024,27 @@ fn render_pr_page(284+         n = pr.number,285+         title = esc(&pr.title),286+     );287+-    body.push_str(&meta_row("state:", &esc(&pr.state)));288+-    body.push_str(&meta_row(289+-        "branches:",290+-        &format!(291+-            "<code>{}</code> ← <code>{}</code>",292+-            esc(&pr.base),293+-            esc(&pr.head)294+-        ),295+-    ));296++    let now = crate::build::now_unix();297++    body.push_str("<div class=\"meta\">");298++    body.push_str(&state_pill(&pr.state));299+     if pr.draft {300+-        body.push_str(&meta_row("draft:", "yes"));301++        body.push_str("<span class=\"chip\">draft</span>");302+     }303+     if let Some(ci) = &pr.ci {304+-        body.push_str(&meta_row("ci:", &verdict_badge(ci)));305++        body.push_str(&verdict_badge(ci));306+     }307++    body.push_str(&format!(308++        "<span class=\"meta-when\">opened {}</span>",309++        time_ago(&pr.created, now)310++    ));311++    body.push_str("</div>\n");312++    let mut details =313++        String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");314++    details.push_str(&format!(315++        "<dt>branches</dt><dd><code>{}</code> ← <code>{}</code></dd>\n",316++        esc(&pr.base),317++        esc(&pr.head)318++    ));319+     if !pr.issues.is_empty() {320+         let issues = pr321+             .issues322+@@ -989,13 +1052,16 @@ fn render_pr_page(323+             .map(|i| format!("<code>{}</code>", esc(i)))324+             .collect::<Vec<_>>()325+             .join(", ");326+-        body.push_str(&meta_row("issues:", &issues));327++        details.push_str(&format!("<dt>issues</dt><dd>{issues}</dd>\n"));328+     }329+-    body.push_str(&meta_row("created:", &esc(&pr.created)));330+     if let Some(u) = &pr.updated {331+-        body.push_str(&meta_row("updated:", &esc(u)));332++        details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));333+     }334+-    body.push_str(&meta_row("id:", &format!("<code>{}</code>", esc(&pr.id))));335++    details.push_str(&format!(336++        "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",337++        esc(&pr.id)338++    ));339++    body.push_str(&details);340+     body.push_str(&markdown_to_html(&pr.body));341+     body.push_str("<h2>Patch</h2>\n");342+     match &pr.patch {343+@@ -1013,7 +1079,7 @@ fn render_pr_page(344+     } else {345+         body.push_str("<ul class=\"comments\">\n");346+         for c in &pr.comments {347+-            body.push_str(&render_comment(c));348++            body.push_str(&render_comment(c, now));349+         }350+         body.push_str("</ul>\n");351+     }352+@@ -1026,9 +1092,10 @@ fn render_pr_page(353+             let state = r.state.as_deref().unwrap_or("commented");354+             let author = r.author.as_deref().unwrap_or("unknown");355+             body.push_str(&format!(356+-                "<li class=\"review\">\n<p class=\"muted\">{} · <code>{}</code> · {}</p>\n{}</li>\n",357+-                esc(&r.created),358++                "<li class=\"review\">\n<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {} · {}</p>\n{}</li>\n",359++                monogram(author),360+                 esc(author),361++                time_ago(&r.created, now),362+                 esc(state),363+                 markdown_to_html(&r.body),364+             ));365+@@ -1060,6 +1127,7 @@ fn render_issues_index(366+     if data.issues.is_empty() {367+         body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");368+     } else {369++        let now = crate::build::now_unix();370+         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");371+         // m6/sort (#48 + #49): open first (newest-first within), then372+         // non-open (newest-first within). Numbers stay creation-rank.373+@@ -1070,9 +1138,9 @@ fn render_issues_index(374+                 n = issue.number,375+                 id = esc(&issue.id),376+                 title = esc(&issue.title),377+-                state = esc(&issue.state),378++                state = state_pill(&issue.state),379+                 comments = issue.comments.len(),380+-                created = esc(&issue.created),381++                created = time_ago(&issue.created, now),382+             ));383+         }384+         body.push_str("</tbody>\n</table></div>\n");385+@@ -1123,6 +1191,7 @@ fn render_pulls_index(386+     if data.prs.is_empty() {387+         body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");388+     } else {389++        let now = crate::build::now_unix();390+         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");391+         // m6/sort (#48 + #49): open (incl drafts) first (newest-first392+         // within), then closed/merged (newest-first within).393+@@ -1139,9 +1208,9 @@ fn render_pulls_index(394+                 id = esc(&pr.id),395+                 title = esc(&pr.title),396+                 full = esc(&full_title),397+-                state = esc(&pr.state),398++                state = state_pill(&pr.state),399+                 lanes_title = esc(&lanes_plain),400+-                created = esc(&pr.created),401++                created = time_ago(&pr.created, now),402+             ));403+         }404+         body.push_str("</tbody>\n</table></div>\n");405+@@ -1222,7 +1291,11 @@ fn render_ci_index(406+     description: Option<&str>,407+ ) -> String {408+     let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");409+-    body.push_str(&meta_row("runner:", &esc(&ci.runner)));410++    let now = crate::build::now_unix();411++    body.push_str(&format!(412++        "<div class=\"meta\"><span class=\"chip\">runner: {}</span></div>\n",413++        esc(&ci.runner)414++    ));415+     if ci.steps.is_empty() {416+         body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");417+     } else {418+@@ -1235,22 +1308,31 @@ fn render_ci_index(419+     body.push_str("<h2>Latest run</h2>\n");420+     match &ci.latest {421+         Some(run) => {422+-            body.push_str(&meta_row("verdict:", &verdict_badge(&run.verdict)));423+-            body.push_str(&meta_row(424+-                "run:",425+-                &format!("<code>{}</code>", esc(&run.run)),426++            let steps = run.steps.len();427++            body.push_str("<div class=\"meta\">");428++            body.push_str(&verdict_badge(&run.verdict));429++            body.push_str(&format!(430++                "<span class=\"meta-when\">ran {} \u{b7} {steps} step{}</span>",431++                time_ago(&run.created, now),432++                if steps == 1 { "" } else { "s" }433++            ));434++            body.push_str("</div>\n");435++            body.push_str(&format!(436++                "<details class=\"meta-more\"><summary>details</summary>\n<dl><dt>run</dt><dd><code>{}</code></dd>\n<dt>created</dt><dd>{}</dd></dl></details>\n",437++                esc(&run.run),438++                time_ago(&run.created, now)439+             ));440+-            body.push_str(&meta_row("created:", &esc(&run.created)));441+             if run.steps.is_empty() {442+                 body.push_str("<p class=\"muted\">No step results recorded.</p>\n");443+             } else {444+-                body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");445++                body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration</th></tr></thead>\n<tbody>\n");446+                 for s in &run.steps {447+                     body.push_str(&format!(448+-                        "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration_ms\" title=\"{dur}\">{dur}</td></tr>\n",449++                        "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration\" title=\"{dur}\">{dur_h}</td></tr>\n",450+                         name = esc(&s.name),451+                         exit = s.exit,452+                         dur = s.duration_ms,453++                        dur_h = fmt_duration(s.duration_ms),454+                     ));455+                 }456+                 body.push_str("</tbody>\n</table></div>\n");457+@@ -2150,7 +2232,7 @@ mod tests {458+                 "pulls/index.html",459+                 vec!["#", "title", "state", "head→base", "created"],460+             ),461+-            ("ci/index.html", vec!["step", "exit", "duration_ms"]),462++            ("ci/index.html", vec!["step", "exit", "duration"]),463+         ] {464+             let html = std::fs::read_to_string(out.join(page)).expect(page);465+             let tds = html.matches("<td").count();466+diff --git a/src/releases.rs b/src/releases.rs467+index 12fbf5a..129d61a 100644468+--- a/src/releases.rs469++++ b/src/releases.rs470+@@ -335,8 +335,8 @@ fn render_release_page(471+         esc(&rel.version)472+     ));473+     body.push_str(&format!(474+-        "<p><span class=\"muted\">created:</span> {}</p>\n",475+-        esc(&rel.created)476++        "<p class=\"muted\">{}</p>\n",477++        time_ago(&rel.created)478+     ));479+     if let Some(sha) = &rel.sha {480+         body.push_str(&format!(481+@@ -380,6 +380,19 @@ fn render_release_page(482+     )483+ }484+ 485++/// Relative time with the exact UTC value in `title` (f0rge #72).486++fn time_ago(s: &str) -> String {487++    match crate::build::parse_ts_secs(s) {488++        Some(ts) => format!(489++            "<time datetime=\"{}\" title=\"{}\">{}</time>",490++            esc(s),491++            esc(&crate::build::ts_title(s)),492++            esc(&crate::build::relative_age(crate::build::now_unix(), ts))493++        ),494++        None => format!("<time>{}</time>", esc(s)),495++    }496++}497++498+ /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded499+ /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.500+ #[allow(clippy::too_many_arguments)]501+@@ -401,11 +414,12 @@ pub fn render_loaded(502+         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");503+         for rel in releases {504+             body.push_str(&format!(505+-                "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",506++                "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{ca}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",507+                 v = esc(&rel.version),508+                 ve = esc(&rel.version),509+                 t = esc(rel.display_title()),510+                 c = esc(&rel.created),511++                ca = time_ago(&rel.created),512+                 n = rel.artifacts.len(),513+             ));514+         }515+diff --git a/static/style.css b/static/style.css516+index ea47b5f..c6ca7a5 100644517+--- a/static/style.css518++++ b/static/style.css519+@@ -311,3 +311,28 @@ details.clonebox:not([open]){padding-bottom:.25rem}520+   .file-scroll{overflow-x:auto}521+   .file-scroll table{width:max-content;min-width:100%}522+ }523++/* Meta pass (f0rge #72): pills, chips, monograms, relative times. */524++.meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2);margin:0 0 var(--s4)}525++.pill{display:inline-flex;align-items:center;padding:.1em .6em;border:1px solid currentColor;font-weight:700;font-size:var(--fs-sm)}526++.pill.state-open{color:var(--state-open)}527++.pill.state-merged{color:var(--state-merged)}528++.pill.state-closed{color:var(--state-closed)}529++.pill.state-other{color:var(--fg-dim)}530++.chip{display:inline-flex;padding:.1em .5em;border:1px solid var(--border);background:var(--surface);color:var(--fg-dim);font-size:var(--fs-sm)}531++.chip.milestone{border-style:dashed}532++.meta-when{color:var(--fg-dim);font-size:var(--fs-sm)}533++.meta-more{margin:0 0 var(--s4);font-size:var(--fs-sm);color:var(--fg-dim)}534++.meta-more summary{cursor:pointer;color:var(--blue)}535++.meta-more dl{display:grid;grid-template-columns:max-content 1fr;gap:var(--s1) var(--s3);margin:var(--s2) 0 0}536++.meta-more dt{font-weight:600}537++.meta-more dd{margin:0;overflow-wrap:anywhere}538++.mono{display:inline-flex;width:1.4rem;height:1.4rem;align-items:center;justify-content:center;font-size:.75rem;font-weight:700;border:1px solid var(--border);background:var(--surface-2);color:var(--fg);vertical-align:-.25rem;margin-right:var(--s2)}539++.mono.m0{color:var(--blue)}540++.mono.m1{color:var(--green)}541++.mono.m2{color:var(--yellow)}542++.mono.m3{color:var(--red)}543++.mono.m4{color:var(--cyan)}544++.mono.m5{color:var(--tok-kw)}545++.comment-head{display:flex;align-items:center;color:var(--fg-dim);font-size:var(--fs-sm);margin:0 0 var(--s2)}546++.comment-head .who{color:var(--fg);font-weight:600;margin-right:.35em}547++time{font-variant-numeric:tabular-nums}548+-- 549+2.56.0550+
.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/pr.md+13 −0
diff --git a/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/pr.md b/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/pr.mdnew file mode 100644index 0000000..f920a2c--- /dev/null+++ b/.forge/prs/86d9d56e-9d80-4941-8be1-e33f182d475d/pr.md@@ -0,0 +1,13 @@1+---2+id: 86d9d56e-9d80-4941-8be1-e33f182d475d3+title: 'Meta pass: pills, chips, monograms, relative dates (#72)'4+state: merged5+base: master6+head: issue-72-meta-pass7+draft: false8+issues:9+- bd97c1d0-b90e-45df-b3e9-ab1182f8382a10+ci: pass11+created: 2026-10-27T16:10:38.497495506Z12+updated: 2026-10-27T16:11:29.027971502Z13+---