diff --git a/src/forge.rs b/src/forge.rsindex 621deff..500f6d6 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -867,21 +867,75 @@ pub fn verdict_badge(verdict: &str) -> String {867867 }868868 }869869 870-fn meta_row(label: &str, value: &str) -> String {870+/// CSS class for a state pill (open/closed/merged/other).871+fn state_class(state: &str) -> &'static str {872+ match state.trim().to_ascii_lowercase().as_str() {873+ "open" => "open",874+ "closed" => "closed",875+ "merged" => "merged",876+ _ => "other",877+ }878+}879+880+/// Colored state pill (f0rge #72).881+fn state_pill(state: &str) -> String {871882 format!(872- "<p><span class=\"muted\">{}</span> {}</p>\n",873- esc(label),874- value883+ "<span class=\"pill state-{}\">{}</span>",884+ state_class(state),885+ esc(state.trim())875886 )876887 }877888 878-fn render_comment(c: &Comment) -> String {889+/// 1-letter author monogram with a deterministic hue class.890+fn monogram(handle: &str) -> String {891+ let h = handle.trim();892+ let initial: String = h893+ .chars()894+ .next()895+ .map(|c| c.to_uppercase().to_string())896+ .unwrap_or_default();897+ let mut hash: u32 = 0;898+ for b in h.bytes() {899+ hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));900+ }901+ format!(902+ "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",903+ hash % 6,904+ esc(&initial)905+ )906+}907+908+/// `<time>` element with a relative label and the exact UTC value in909+/// `title`/`datetime` (never a raw nanosecond ISO string in visible text).910+fn time_ago(s: &str, now: i64) -> String {911+ match crate::build::parse_ts_secs(s) {912+ Some(ts) => format!(913+ "<time datetime=\"{}\" title=\"{}\">{}</time>",914+ esc(s),915+ esc(&crate::build::ts_title(s)),916+ esc(&crate::build::relative_age(now, ts))917+ ),918+ None => format!("<time>{}</time>", esc(s)),919+ }920+}921+922+/// Human duration for CI steps: `7 ms` under a second, else `1.2 s`.923+fn fmt_duration(ms: u64) -> String {924+ if ms < 1000 {925+ format!("{ms} ms")926+ } else {927+ format!("{:.1} s", ms as f64 / 1000.0)928+ }929+}930+931+fn render_comment(c: &Comment, now: i64) -> String {879932 let mut out = String::from("<li class=\"comment\">\n");880933 let author = c.author.as_deref().unwrap_or("unknown");881934 out.push_str(&format!(882- "<p class=\"muted\">{} · <code>{}</code></p>\n",883- esc(&c.created),935+ "<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {}</p>\n",936+ monogram(author),884937 esc(author),938+ time_ago(&c.created, now),885939 ));886940 out.push_str(&markdown_to_html(&c.body));887941 out.push_str("</li>\n");@@ -901,36 +955,39 @@ fn render_issue_page(901955 n = issue.number,902956 title = esc(&issue.title),903957 );904- body.push_str(&meta_row("state:", &esc(&issue.state)));905- if !issue.labels.is_empty() {906- let labels = issue907- .labels908- .iter()909- .map(|l| esc(l))910- .collect::<Vec<_>>()911- .join(", ");912- body.push_str(&meta_row("labels:", &labels));913- }914- if !issue.assignees.is_empty() {915- let assignees = issue916- .assignees917- .iter()918- .map(|a| esc(a))919- .collect::<Vec<_>>()920- .join(", ");921- body.push_str(&meta_row("assignees:", &assignees));958+ let now = crate::build::now_unix();959+ body.push_str("<div class=\"meta\">");960+ body.push_str(&state_pill(&issue.state));961+ for l in &issue.labels {962+ body.push_str(&format!("<span class=\"chip\">{}</span>", esc(l)));922963 }923964 if let Some(m) = &issue.milestone {924- body.push_str(&meta_row("milestone:", &esc(m)));965+ body.push_str(&format!("<span class=\"chip milestone\">{}</span>", esc(m)));925966 }926- body.push_str(&meta_row("created:", &esc(&issue.created)));927- if let Some(u) = &issue.updated {928- body.push_str(&meta_row("updated:", &esc(u)));929- }930- body.push_str(&meta_row(931- "id:",932- &format!("<code>{}</code>", esc(&issue.id)),967+ body.push_str(&format!(968+ "<span class=\"meta-when\">opened {}</span>",969+ time_ago(&issue.created, now)933970 ));971+ body.push_str("</div>\n");972+ let mut details =973+ String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");974+ if !issue.assignees.is_empty() {975+ let who = issue976+ .assignees977+ .iter()978+ .map(|a| format!("{}{}", monogram(a), esc(a)))979+ .collect::<Vec<_>>()980+ .join(", ");981+ details.push_str(&format!("<dt>assignees</dt><dd>{who}</dd>\n"));982+ }983+ if let Some(u) = &issue.updated {984+ details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));985+ }986+ details.push_str(&format!(987+ "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",988+ esc(&issue.id)989+ ));990+ body.push_str(&details);934991 body.push_str(&markdown_to_html(&issue.body));935992 body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));936993 if issue.comments.is_empty() {@@ -938,7 +995,7 @@ fn render_issue_page(938995 } else {939996 body.push_str("<ul class=\"comments\">\n");940997 for c in &issue.comments {941- body.push_str(&render_comment(c));998+ body.push_str(&render_comment(c, now));942999 }9431000 body.push_str("</ul>\n");9441001 }@@ -967,21 +1024,27 @@ fn render_pr_page(9671024 n = pr.number,9681025 title = esc(&pr.title),9691026 );970- body.push_str(&meta_row("state:", &esc(&pr.state)));971- body.push_str(&meta_row(972- "branches:",973- &format!(974- "<code>{}</code> ← <code>{}</code>",975- esc(&pr.base),976- esc(&pr.head)977- ),978- ));1027+ let now = crate::build::now_unix();1028+ body.push_str("<div class=\"meta\">");1029+ body.push_str(&state_pill(&pr.state));9791030 if pr.draft {980- body.push_str(&meta_row("draft:", "yes"));1031+ body.push_str("<span class=\"chip\">draft</span>");9811032 }9821033 if let Some(ci) = &pr.ci {983- body.push_str(&meta_row("ci:", &verdict_badge(ci)));1034+ body.push_str(&verdict_badge(ci));9841035 }1036+ body.push_str(&format!(1037+ "<span class=\"meta-when\">opened {}</span>",1038+ time_ago(&pr.created, now)1039+ ));1040+ body.push_str("</div>\n");1041+ let mut details =1042+ String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");1043+ details.push_str(&format!(1044+ "<dt>branches</dt><dd><code>{}</code> ← <code>{}</code></dd>\n",1045+ esc(&pr.base),1046+ esc(&pr.head)1047+ ));9851048 if !pr.issues.is_empty() {9861049 let issues = pr9871050 .issues@@ -989,13 +1052,16 @@ fn render_pr_page(9891052 .map(|i| format!("<code>{}</code>", esc(i)))9901053 .collect::<Vec<_>>()9911054 .join(", ");992- body.push_str(&meta_row("issues:", &issues));1055+ details.push_str(&format!("<dt>issues</dt><dd>{issues}</dd>\n"));9931056 }994- body.push_str(&meta_row("created:", &esc(&pr.created)));9951057 if let Some(u) = &pr.updated {996- body.push_str(&meta_row("updated:", &esc(u)));1058+ details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));9971059 }998- body.push_str(&meta_row("id:", &format!("<code>{}</code>", esc(&pr.id))));1060+ details.push_str(&format!(1061+ "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",1062+ esc(&pr.id)1063+ ));1064+ body.push_str(&details);9991065 body.push_str(&markdown_to_html(&pr.body));10001066 body.push_str("<h2>Patch</h2>\n");10011067 match &pr.patch {@@ -1013,7 +1079,7 @@ fn render_pr_page(10131079 } else {10141080 body.push_str("<ul class=\"comments\">\n");10151081 for c in &pr.comments {1016- body.push_str(&render_comment(c));1082+ body.push_str(&render_comment(c, now));10171083 }10181084 body.push_str("</ul>\n");10191085 }@@ -1026,9 +1092,10 @@ fn render_pr_page(10261092 let state = r.state.as_deref().unwrap_or("commented");10271093 let author = r.author.as_deref().unwrap_or("unknown");10281094 body.push_str(&format!(1029- "<li class=\"review\">\n<p class=\"muted\">{} · <code>{}</code> · {}</p>\n{}</li>\n",1030- esc(&r.created),1095+ "<li class=\"review\">\n<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {} · {}</p>\n{}</li>\n",1096+ monogram(author),10311097 esc(author),1098+ time_ago(&r.created, now),10321099 esc(state),10331100 markdown_to_html(&r.body),10341101 ));@@ -1060,6 +1127,7 @@ fn render_issues_index(10601127 if data.issues.is_empty() {10611128 body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");10621129 } else {1130+ let now = crate::build::now_unix();10631131 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");10641132 // m6/sort (#48 + #49): open first (newest-first within), then10651133 // non-open (newest-first within). Numbers stay creation-rank.@@ -1070,9 +1138,9 @@ fn render_issues_index(10701138 n = issue.number,10711139 id = esc(&issue.id),10721140 title = esc(&issue.title),1073- state = esc(&issue.state),1141+ state = state_pill(&issue.state),10741142 comments = issue.comments.len(),1075- created = esc(&issue.created),1143+ created = time_ago(&issue.created, now),10761144 ));10771145 }10781146 body.push_str("</tbody>\n</table></div>\n");@@ -1123,6 +1191,7 @@ fn render_pulls_index(11231191 if data.prs.is_empty() {11241192 body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");11251193 } else {1194+ let now = crate::build::now_unix();11261195 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");11271196 // m6/sort (#48 + #49): open (incl drafts) first (newest-first11281197 // within), then closed/merged (newest-first within).@@ -1139,9 +1208,9 @@ fn render_pulls_index(11391208 id = esc(&pr.id),11401209 title = esc(&pr.title),11411210 full = esc(&full_title),1142- state = esc(&pr.state),1211+ state = state_pill(&pr.state),11431212 lanes_title = esc(&lanes_plain),1144- created = esc(&pr.created),1213+ created = time_ago(&pr.created, now),11451214 ));11461215 }11471216 body.push_str("</tbody>\n</table></div>\n");@@ -1222,7 +1291,11 @@ fn render_ci_index(12221291 description: Option<&str>,12231292 ) -> String {12241293 let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");1225- body.push_str(&meta_row("runner:", &esc(&ci.runner)));1294+ let now = crate::build::now_unix();1295+ body.push_str(&format!(1296+ "<div class=\"meta\"><span class=\"chip\">runner: {}</span></div>\n",1297+ esc(&ci.runner)1298+ ));12261299 if ci.steps.is_empty() {12271300 body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");12281301 } else {@@ -1235,22 +1308,31 @@ fn render_ci_index(12351308 body.push_str("<h2>Latest run</h2>\n");12361309 match &ci.latest {12371310 Some(run) => {1238- body.push_str(&meta_row("verdict:", &verdict_badge(&run.verdict)));1239- body.push_str(&meta_row(1240- "run:",1241- &format!("<code>{}</code>", esc(&run.run)),1311+ let steps = run.steps.len();1312+ body.push_str("<div class=\"meta\">");1313+ body.push_str(&verdict_badge(&run.verdict));1314+ body.push_str(&format!(1315+ "<span class=\"meta-when\">ran {} \u{b7} {steps} step{}</span>",1316+ time_ago(&run.created, now),1317+ if steps == 1 { "" } else { "s" }1318+ ));1319+ body.push_str("</div>\n");1320+ body.push_str(&format!(1321+ "<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",1322+ esc(&run.run),1323+ time_ago(&run.created, now)12421324 ));1243- body.push_str(&meta_row("created:", &esc(&run.created)));12441325 if run.steps.is_empty() {12451326 body.push_str("<p class=\"muted\">No step results recorded.</p>\n");12461327 } else {1247- 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");1328+ 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");12481329 for s in &run.steps {12491330 body.push_str(&format!(1250- "<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",1331+ "<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",12511332 name = esc(&s.name),12521333 exit = s.exit,12531334 dur = s.duration_ms,1335+ dur_h = fmt_duration(s.duration_ms),12541336 ));12551337 }12561338 body.push_str("</tbody>\n</table></div>\n");@@ -2150,7 +2232,7 @@ mod tests {21502232 "pulls/index.html",21512233 vec!["#", "title", "state", "head→base", "created"],21522234 ),2153- ("ci/index.html", vec!["step", "exit", "duration_ms"]),2235+ ("ci/index.html", vec!["step", "exit", "duration"]),21542236 ] {21552237 let html = std::fs::read_to_string(out.join(page)).expect(page);21562238 let tds = html.matches("<td").count();