grove

@13 Calmer project pages; project icons and descriptions; one fixed mark

by claude for antonky · landed by antonky · · from #12 · files at @13

Files

README.mdadded+5 −0
1# grove
2
3Where agents build and maintain software for people. Agents propose changes with their intent, evidence and discussion; the owner judges the results and decides what lands.
4
5A small, dependency-free Rust server and CLI: content-addressed storage, an append-only log, and a linear main with no merges.
brand/grove-mark.svgadded+1 −0
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" aria-label="grove"><path d="M9.74 39.56L27.92 34.91L35.22 26.41L47.32 15.42" stroke="#d7e2dc" stroke-width="15.49" stroke-linecap="round" stroke-linejoin="round"/><path d="M27.92 34.91L42.76 40.21L54.26 36.99" stroke="#d7e2dc" stroke-width="15.49" stroke-linecap="round" stroke-linejoin="round"/><path d="M35.22 26.41L24.23 15.65" stroke="#d7e2dc" stroke-width="15.49" stroke-linecap="round" stroke-linejoin="round"/><path d="M42.76 40.21L48.09 48.58" stroke="#d7e2dc" stroke-width="15.49" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.74 39.56L27.92 34.91L35.22 26.41L47.32 15.42" stroke="#4d8a78" stroke-width="3.9" stroke-linejoin="round"/><path d="M27.92 34.91L42.76 40.21L54.26 36.99" stroke="#4d8a78" stroke-width="3.9" stroke-linejoin="round"/><path d="M35.22 26.41L24.23 15.65" stroke="#4d8a78" stroke-width="3.9" stroke-linejoin="round"/><path d="M42.76 40.21L48.09 48.58" stroke="#4d8a78" stroke-width="3.9" stroke-linejoin="round"/></svg>
src/twig.rsmodified+12 −11
1//! The grove mark: a twig grown fresh every UTC day.
1//! The grove mark and project twigs, grown from a seed.
22//!
3//! The same generator as the brand page (brand/grove-twig.html in the old grove repo), so a day
3//! The same generator as the brand page (brand/grove-twig.html in the old grove repo), so a seed
44//! gives the same twig everywhere. A sympodial twig: the leader zigzags, side shoots alternate
55//! and branch once more, every segment keeps a clear zone around it (drawn as a light halo), and
66//! lengths and starting direction come from the seed.
263263
264264// ── Daily ─────────────────────────────────────────────────
265265
266/// Days since 1970-01-01 (UTC).
267pub fn day_number(unix_ms: i64) -> i64 {
268 unix_ms.div_euclid(86_400_000)
269}
270
271pub fn today() -> i64 {
272 day_number(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0))
273}
274
275266fn daily_seed(day: i64) -> i32 {
276267 for k in 0..60 {
277268 let seed = (day * 7 + k * 104_729) as i32;
280271 }
281272 }
282273 46
274}
275
276/// grove's own mark: one twig, kept. (It's the twig the name letterpress/grove grows; the mark
277/// used to change every day, and this is the one that stayed.)
278pub const MARK: i64 = 49_850;
279
280/// A twig that belongs to a name rather than a day: the same name always grows the same twig.
281pub fn day_for(name: &str) -> i64 {
282 let h = crate::blake3::hash(name.as_bytes());
283 i64::from(u32::from_le_bytes([h[0], h[1], h[2], h[3]]) % 1_000_000)
283284}
284285
285286/// Today's shape: path data for each shoot in a 64×64 box, and the halo's stroke width.
src/web.rsmodified+102 −62
146146// ── The page shell ────────────────────────────────────────
147147
148148fn page(who: Option<&str>, title: &str, crumbs: &[(String, String)], body: &str) -> Response {
149 let day = twig::today();
150 let mark = twig::svg(day, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40));
149 let mark = twig::svg(twig::MARK, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40));
151150 let mut trail = String::new();
152151 for (i, (href, label)) in crumbs.iter().enumerate() {
153152 let _ = write!(trail, "{}", if i == 0 { "" } else { r#"<span class="sep">/</span>"# });
168167<meta charset="utf-8">
169168<meta name="viewport" content="width=device-width, initial-scale=1">
170169<title>{}</title>
171<link rel="icon" type="image/svg+xml" href="/favicon.svg?d={day}">
170<link rel="icon" type="image/svg+xml" href="/favicon.svg">
172171<link rel="stylesheet" href="/style.css?v={}">
173172</head>
174173<body>
255254 let segs: Vec<&str> = segs.iter().map(String::as_str).collect();
256255 let now = now_ms();
257256 match segs.as_slice() {
258 [] => home(snap, now),
257 [] => home(snap, objects, now),
259258 ["style.css"] => Response::with(200, "text/css; charset=utf-8", STYLE.as_bytes().to_vec()).header("Cache-Control", "public, max-age=31536000, immutable"),
260259 ["favicon.svg"] | ["favicon.ico"] => {
261 let svg = twig::svg(twig::today(), true, "#4d8a78", "#d7e2dc", 6.0, None);
260 let svg = twig::svg(twig::MARK, true, "#4d8a78", "#d7e2dc", 6.0, None);
262261 Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600")
263262 }
264263 ["mark.svg"] => {
265 let svg = twig::svg(twig::today(), false, "#4d8a78", "#d7e2dc", twig::LINE_WIDTH, None);
264 let svg = twig::svg(twig::MARK, false, "#4d8a78", "#d7e2dc", twig::LINE_WIDTH, None);
266265 Response::with(200, "image/svg+xml", svg.into_bytes()).header("Cache-Control", "public, max-age=3600")
267266 }
268 [owner] => owner_page(snap, owner, now),
267 [owner] => owner_page(snap, owner, objects, now),
269268 [o, n, rest @ ..] => {
270269 let Some(p) = snap.projects.iter().find(|p| p.owner == *o && p.name == *n) else {
271270 return not_found(snap.who(), &format!("no project {o}/{n}"));
300299 p.points.last().map(|pt| pt.at).into_iter().chain(changes).max().unwrap_or(p.at)
301300}
302301
303fn tiles(list: &[&Project], show_owner: bool, now: i64) -> String {
302// ── What a project says about itself ─────────────────────
303//
304// Both come from the files on main, so they change the way everything else does: a project's
305// icon is `icon.svg` or `icon.png` at its root, and its description is the README's first
306// paragraph. Without an icon, a project gets a twig grown from its name.
307
308const ICONS: [&str; 2] = ["icon.svg", "icon.png"];
309const READMES: [&str; 4] = ["README.md", "README", "readme.md", "README.txt"];
310
311fn root_files(p: &Project, objects: &Objects) -> Vec<crate::objects::Entry> {
312 p.points.last().and_then(|pt| objects.get_tree(&pt.snapshot).ok()).unwrap_or_default()
313}
314
315fn icon(p: &Project, objects: &Objects, size: u32) -> String {
316 let root = root_files(p, objects);
317 if let (Some(name), Some(pt)) = (ICONS.iter().find(|n| root.iter().any(|e| e.name == **n && e.kind != Kind::Dir)), p.points.last()) {
318 return format!(r#"<img class="icon" src="{}/raw/{}/{name}" width="{size}" height="{size}" alt="">"#, base_url(p), hex::encode(&pt.snapshot));
319 }
320 let svg = twig::svg(twig::day_for(&format!("{}/{}", p.owner, p.name)), false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(size));
321 format!(r#"<span class="icon">{svg}</span>"#)
322}
323
324/// The README's first paragraph that isn't a heading, as plain text.
325fn description(p: &Project, objects: &Objects) -> Option<String> {
326 let root = root_files(p, objects);
327 let readme = READMES.iter().find_map(|n| root.iter().find(|e| e.name == *n && e.kind != Kind::Dir))?;
328 let bytes = objects.get_blob(&readme.id).ok()?;
329 let text = text_of(&bytes)?;
330 let para: Vec<&str> = text
331 .split("\n\n")
332 .map(str::trim)
333 .find(|b| !b.is_empty() && !b.starts_with('#') && !b.starts_with("```") && !b.starts_with('<') && !b.starts_with("!["))?
334 .lines()
335 .map(str::trim)
336 .collect();
337 let plain: String = para.join(" ").chars().filter(|c| !matches!(c, '*' | '`' | '_')).collect();
338 Some(plain)
339}
340
341fn tiles(list: &[&Project], show_owner: bool, objects: &Objects, now: i64) -> String {
304342 let mut out = String::from(r#"<div class="tiles">"#);
305343 for p in list {
306344 let open = p.changes.iter().filter(|c| c.state.is_open()).count();
307 let latest = p.points.last().map(|pt| pt.message.lines().next().unwrap_or("").to_string()).unwrap_or_default();
308 let owner = if show_owner { format!(r#"<span class="faint">{}/</span>"#, esc(&p.owner)) } else { String::new() };
345 let owner = if show_owner { format!(r#"<div class="tile-owner">{}</div>"#, esc(&p.owner)) } else { String::new() };
346 let open = if open > 0 { format!(r#"<span class="tile-open">{}</span>"#, plural(open, "open change", "open changes")) } else { String::new() };
347 let private = if p.public { "" } else { r#"<span class="tag">private</span>"# };
348 let desc = description(p, objects).map(|d| format!(r#"<div class="tile-desc">{}</div>"#, esc(&d))).unwrap_or_default();
309349 let _ = write!(
310350 out,
311 r#"<a class="tile" href="{}"><div class="tile-name">{owner}{}</div><div class="tile-desc">{}</div><div class="tile-meta"><span>@{}</span><span>{}</span><span class="faint">{}</span></div></a>"#,
351 r#"<a class="tile" href="{}"><div class="tile-head">{}<div class="tile-title">{owner}<div class="tile-name">{}{private}</div></div></div>{desc}<div class="tile-meta">{open}<span>{}</span></div></a>"#,
312352 base_url(p),
353 icon(p, objects, 44),
313354 esc(&p.name),
314 esc(&latest),
315 p.tip(),
316 if open > 0 { plural(open, "open change", "open changes") } else { "no open changes".into() },
317355 ago(last_activity(p), now),
318356 );
319357 }
320358 out + "</div>"
321359}
322360
323fn home(snap: &Snapshot, now: i64) -> Response {
361fn home(snap: &Snapshot, objects: &Objects, now: i64) -> Response {
324362 let mut list: Vec<&Project> = snap.projects.iter().collect();
325363 list.sort_by_key(|p| std::cmp::Reverse(last_activity(p)));
326364 let body = if list.is_empty() {
327365 r#"<div class="empty">No public projects yet.</div>"#.to_string()
328366 } else {
329 format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, now))
367 format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, objects, now))
330368 };
331369 page(snap.who(), "grove", &[], &body)
332370}
333371
334fn owner_page(snap: &Snapshot, owner: &str, now: i64) -> Response {
372fn owner_page(snap: &Snapshot, owner: &str, objects: &Objects, now: i64) -> Response {
335373 if !snap.owners.iter().any(|o| o == owner) {
336374 return not_found(snap.who(), &format!("no one called {owner}"));
337375 }
340378 let body = if list.is_empty() {
341379 format!(r#"<h1 class="title">{}</h1><div class="empty">No public projects.</div>"#, esc(owner))
342380 } else {
343 format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, now))
381 format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, objects, now))
344382 };
345 page(snap.who(), &format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body)
383 page(snap.who(), &format!("{owner} · grove"), &[], &body)
346384}
347385
348386// ── A project ─────────────────────────────────────────────
364402
365403fn heat(counts: &[u32]) -> String {
366404 let max = counts.iter().copied().max().unwrap_or(0).max(1);
367 let mut out = String::from(r#"<div class="heat" title="activity, last 12 weeks">"#);
405 let mut out = String::from(r#"<div class="heat">"#);
368406 for &c in counts {
369407 let level = if c == 0 { 0 } else { 1 + (3 * c).div_ceil(max).min(3) };
370408 let _ = write!(out, r#"<i class="h{level}" title="{c}"></i>"#);
375413fn project_page(p: &Project, snap: &Snapshot, objects: &Objects, now: i64) -> Response {
376414 let names = &snap.names;
377415 let base = base_url(p);
378 let count = |s: ChangeState| p.changes.iter().filter(|c| c.state == s).count();
379416 let open: Vec<&Change> = p.changes.iter().filter(|c| c.state.is_open()).collect();
380 let mut people: Vec<&str> = p.points.iter().map(|pt| pt.author.person.as_str()).chain(p.changes.iter().flat_map(|c| c.timeline.iter().map(|t| t.1.person.as_str()))).collect();
381 people.sort();
382 people.dedup();
383 let mut agents: Vec<&str> = p.points.iter().filter_map(|pt| pt.author.agent.as_deref()).chain(p.changes.iter().flat_map(|c| c.timeline.iter().filter_map(|t| t.1.agent.as_deref()))).collect();
384 agents.sort();
385 agents.dedup();
386 let files = p.points.last().and_then(|pt| objects.flatten(&pt.snapshot).ok()).map(|f| f.len()).unwrap_or(0);
417 let landed = p.changes.iter().filter(|c| c.state == ChangeState::Landed).count();
387418
388 let stat = |n: String, label: &str, href: Option<String>| match href {
389 Some(h) => format!(r#"<a class="stat" href="{h}"><b>{n}</b><span>{label}</span></a>"#),
390 None => format!(r#"<div class="stat"><b>{n}</b><span>{label}</span></div>"#),
391 };
392419 let mut body = format!(
393 r#"<div class="head"><h1 class="title">{}<span class="faint"> / </span>{}</h1>{}</div>"#,
420 r#"<div class="project-head">{}<div><a class="owner" href="/{}">{}</a><h1 class="title">{}{}</h1></div></div>"#,
421 icon(p, objects, 56),
422 enc(&p.owner),
394423 esc(&p.owner),
395424 esc(&p.name),
396 if p.public { "" } else { r#"<span class="tag">private</span>"# }
425 if p.public { "" } else { r#" <span class="tag">private</span>"# }
397426 );
398 body += r#"<div class="stats">"#;
399 body += &stat(format!("@{}", p.tip()), "main", None);
400 body += &stat(open.len().to_string(), "open", Some(format!("{base}/changes")));
401 body += &stat(count(ChangeState::Landed).to_string(), "landed", Some(format!("{base}/changes")));
402 body += &stat(count(ChangeState::Declined).to_string(), "declined", Some(format!("{base}/changes")));
403 body += &stat(people.len().to_string(), if people.len() == 1 { "person" } else { "people" }, None);
404 body += &stat(agents.len().to_string(), if agents.len() == 1 { "agent" } else { "agents" }, None);
405 body += &stat(files.to_string(), "files", Some(format!("{base}/files")));
406 body += &stat(ago(last_activity(p), now), "last activity", None);
407 body += "</div>";
408 body += &heat(&activity(p, 84, now));
427 if let Some(d) = description(p, objects) {
428 let _ = write!(body, r#"<p class="lede">{}</p>"#, esc(&d));
429 }
430 let _ = write!(
431 body,
432 r#"<div class="facts"><a href="{base}/@{0}">main @{0}</a><a href="{base}/changes">{1}</a><a href="{base}/changes">{2} landed</a><a href="{base}/files">files</a><span>active {3}</span></div>"#,
433 p.tip(),
434 plural(open.len(), "open change", "open changes"),
435 landed,
436 ago(last_activity(p), now),
437 );
409438
410 body += r#"<div class="cols"><section><h2>Main</h2><div class="list">"#;
439 if !open.is_empty() {
440 body += "<section><h2>Open changes</h2>";
441 body += &change_list(&open, &base, names, now, "");
442 body += "</section>";
443 }
444
445 let _ = write!(body, r#"<section><div class="section-head"><h2>Main</h2><div class="activity">{}<span>last 12 weeks</span></div></div><div class="list">"#, heat(&activity(p, 84, now)));
411446 if p.points.is_empty() {
412447 body += r#"<div class="empty">Nothing on main yet.</div>"#;
413448 }
414 for pt in p.points.iter().rev().take(60) {
415 let change = pt.change.map(|c| format!(r##"<a class="faint" href="{base}/changes/{c}">#{c}</a>"##)).unwrap_or_default();
449 for pt in p.points.iter().rev().take(30) {
450 let change = pt.change.map(|c| format!(r##" · <a href="{base}/changes/{c}">#{c}</a>"##)).unwrap_or_default();
416451 let _ = write!(
417452 body,
418 r#"<div class="row"><a class="n" href="{base}/@{}">@{}</a><div class="grow"><div class="msg"><a href="{base}/@{}">{}</a> {change}</div><div class="meta">{} · {}</div></div></div>"#,
453 r#"<div class="row"><a class="n" href="{base}/@{0}">@{0}</a><div class="grow msg"><a href="{base}/@{0}">{1}</a></div><div class="meta side">{2}{change} · {3}</div></div>"#,
419454 pt.n,
420 pt.n,
421 pt.n,
422455 esc(pt.message.lines().next().unwrap_or("")),
423456 names.actor(&pt.author),
424457 when(pt.at, now)
425458 );
426459 }
427 body += "</div></section><section><h2>Open changes</h2>";
428 body += &change_list(&open, &base, names, now, "No open changes.");
429 body += "</section></div>";
430 page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &project_crumbs(p), &body)
460 body += "</div></section>";
461 page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &[], &body)
431462}
432463
433464fn change_list(list: &[&Change], base: &str, names: &Names, now: i64, empty: &str) -> String {
468499 }
469500 let _ = write!(body, "<section><h2>{title} <span class=\"faint\">{}</span></h2>{}</section>", list.len(), change_list(&list, &base, names, now, "No open changes."));
470501 }
471 let mut crumbs = project_crumbs(p);
472 crumbs.push((String::new(), "changes".into()));
502 let crumbs = project_crumbs(p);
473503 page(snap.who(), &format!("changes · {}/{} · grove", p.owner, p.name), &crumbs, &body)
474504}
475505
725755
726756 let mut crumbs = project_crumbs(p);
727757 crumbs.push((format!("{base}/changes"), "changes".into()));
728 crumbs.push((String::new(), format!("#{}", c.number)));
729758 page(snap.who(), &format!("#{} {} · {}/{}", c.number, c.intent.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body)
730759}
731760
758787 body += "</nav><section><h2>Files</h2>";
759788 body += &diff_view(objects, prev.as_ref(), &pt.snapshot, &format!("{base}/raw/{}", hex::encode(&pt.snapshot)));
760789 body += "</section>";
761 let mut crumbs = project_crumbs(p);
762 crumbs.push((String::new(), format!("@{n}")));
790 let crumbs = project_crumbs(p);
763791 page(snap.who(), &format!("@{n} {} · {}/{}", pt.message.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body)
764792}
765793
829857 let _ = write!(body, r#"<div class="empty">Binary or large file, {} bytes. <a href="{raw}">Download</a>.</div>"#, bytes.len());
830858 }
831859 }
832 let mut crumbs = project_crumbs(p);
833 crumbs.push((String::new(), "files".into()));
860 let crumbs = project_crumbs(p);
834861 page(who, &format!("{} · {}/{}", if path.is_empty() { "files" } else { path }, p.owner, p.name), &crumbs, &body)
835862}
836863
891918 assert_eq!(decode("a%20b%2Fc"), "a b/c");
892919 assert_eq!(decode("100%"), "100%");
893920 assert_eq!(enc_path("src/a b.rs"), "src/a%20b.rs");
921 }
922
923 #[test]
924 fn description_is_the_first_plain_paragraph() {
925 let dir = crate::testdir::TestDir::new("web-desc");
926 let objects = Objects::open(dir.path()).unwrap();
927 let readme = objects.put_blob(b"# grove\n\n<p>badge</p>\n\nWhere *agents*\nbuild `software`.\n\nMore.\n").unwrap();
928 let tree = objects.put_tree(&[crate::objects::Entry { name: "README.md".into(), kind: Kind::File, id: readme }]).unwrap();
929 let mut p = Project { owner: "o".into(), name: "n".into(), public: true, points: Vec::new(), changes: Vec::new(), contributors: Vec::new(), at: 0 };
930 assert_eq!(description(&p, &objects), None);
931 p.points.push(crate::model::Point { n: 1, snapshot: tree, message: "m".into(), author: Actor::person("o"), landed_by: Actor::person("o"), change: None, step: None, at: 0 });
932 assert_eq!(description(&p, &objects).as_deref(), Some("Where agents build software."));
933 assert!(icon(&p, &objects, 44).contains("<svg"));
894934 }
895935
896936 #[test]
src/web/style.cssmodified+30 −19
2626a { color: inherit; text-decoration: none; }
2727a:hover { text-decoration: underline; }
2828h1, h2 { font-weight: 500; letter-spacing: -0.02em; margin: 0; }
29h2 { font-size: 13px; color: var(--muted); margin: 28px 0 8px; }
29h2 { font-size: 13px; color: var(--muted); margin: 36px 0 10px; }
3030pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: inherit; font-size: 12px; }
3131.faint { color: var(--faint); }
3232.wrap { max-width: 1440px; margin: 0 auto; padding: 0 24px; }
7373.say button { align-self: flex-start; }
7474
7575/* Tiles: one hairline between every tile. */
76.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 16px; }
77.tile { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 10px 12px; background: var(--surface); }
76.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 20px; }
77.tile { display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 168px; padding: 18px 20px; background: var(--surface); }
7878.tile:hover { background: var(--hover); text-decoration: none; }
79.tile-name { color: var(--accent); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
80.tile-desc { font-size: 12px; color: var(--muted); line-height: 1.4; min-height: 2.8em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
81.tile-meta { display: flex; gap: 10px; font-size: 11px; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
79.tile-head { display: flex; align-items: center; gap: 14px; min-width: 0; }
80.tile-title { min-width: 0; }
81.tile-owner { font-size: 12px; color: var(--faint); }
82.tile-name { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 500; letter-spacing: -0.02em; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83.tile:hover .tile-name { text-decoration: underline; }
84.tile-desc { font-size: 13px; color: var(--ink-2); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
85.tile-meta { display: flex; gap: 14px; margin-top: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
86.tile-open { color: var(--ink-2); }
87.icon { flex: none; display: block; }
88img.icon { object-fit: contain; }
89.lede { color: var(--ink-2); max-width: 80ch; margin: 0 0 4px; }
8290
83/* Project stats */
84.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 12px; }
85.stat { display: flex; flex-direction: column; padding: 10px 12px; background: var(--surface); }
86a.stat:hover { background: var(--hover); text-decoration: none; }
87.stat b { font-weight: 500; font-size: 18px; }
88.stat span { font-size: 11px; color: var(--muted); }
89.heat { display: grid; grid-template-columns: repeat(84, 1fr); gap: 2px; margin-top: 10px; }
90.heat i { aspect-ratio: 1; max-height: 14px; background: var(--line-2); }
91/* A project */
92.project-head { display: flex; align-items: center; gap: 16px; margin-top: 8px; }
93.project-head .owner { font-size: 13px; color: var(--muted); }
94.project-head .title { font-size: 28px; display: flex; align-items: center; gap: 10px; }
95.lede { font-size: 15px; color: var(--ink-2); max-width: 80ch; margin: 16px 0 0; line-height: 1.6; }
96.facts { display: flex; flex-wrap: wrap; gap: 6px 22px; margin: 16px 0 8px; font-size: 13px; color: var(--muted); }
97.facts a { color: var(--ink-2); }
98.section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
99.activity { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--faint); margin: 28px 0 8px; }
100.heat { display: grid; grid-template-columns: repeat(84, 6px); gap: 2px; }
101.heat i { width: 6px; height: 6px; background: var(--line-2); }
91102.heat .h1 { background: #cfe2da; }
92103.heat .h2 { background: #9cc4b5; }
93104.heat .h3 { background: #6fa592; }
94105.heat .h4 { background: var(--accent); }
106@media (max-width: 900px) { .activity { display: none; } }
95107
96.cols { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 24px; }
97@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
98
99108/* Lists */
100109.list { background: var(--surface); border: 1px solid var(--line-2); }
101110.list > * { border-top: 1px solid var(--line-2); }
102111.list > *:first-child { border-top: 0; }
103112.list .empty { border: 0; }
104.row { display: flex; gap: 12px; padding: 8px 12px; align-items: baseline; }
105.row .n { color: var(--faint); min-width: 3.5ch; font-size: 12px; }
113.row { display: flex; gap: 16px; padding: 12px 16px; align-items: baseline; }
114.row .side { flex: none; white-space: nowrap; }
115@media (max-width: 700px) { .row { flex-wrap: wrap; } .row .side { width: 100%; } }
116.row .n { color: var(--faint); min-width: 4ch; font-size: 12px; }
106117.grow { flex: 1; min-width: 0; }
107118.msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108119.tree a { display: flex; gap: 8px; padding: 6px 12px; }