an og card's meta line is a ribbon, a pull's notched holes open through the top edge beside a plain rail, a count of one is singular, and a user's chips sit below the avatar and bio
Signed-off-by: dawn <dawn@klbr.net>
24 unmodified lines252627282930313233343536372930313219 unmodified lines52535455656656575859606124 unmodified lines86878889999091929394959697989910028 unmodified lines12913013113213313413513613914013713813914014114213 unmodified lines15615715815916216316416516616716816917017117217316016116216316416516617516716816917017136 unmodified lines20820921021121221321422222322422522621521621721822921922022122223322322422522622728 unmodified lines25625725825926026126226326427227327426526626726826927013 unmodified lines28428528628728828929029129229329429529629729829930030129730230330430030530630730830430931031131231331431031531631231731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836924 unmodified lines394395396397350351352353354355356357358359360361362363364365366367368369370371372373374375376377398399400401167 unmodified linesExpand all56957057157255357357457557657720 unmodified lines59859960060158260260360460560660760860961059159259359459559659759859960060160260360460561161261361437 unmodified lines24 unmodified lines (4.0, 6.0), (6.0, 4.0), (0.0, 2.0),];/// the ticket rail's fade, its 8x8 cells and their opacityconst RAIL_CELLS: [(f32, f32, f32); 6] = [ (0.0, 0.0, 0.85), (0.0, 4.0, 0.85), (2.0, 2.0, 0.55), (2.0, 6.0, 0.55), (4.0, 0.0, 0.3), (4.0, 4.0, 0.3),];const SCALE: f32 = 3.0;/// a patch card's punch holes, the files take the first 14 and a merge the lastconst HOLE_SLOTS: usize = 16;/// svfe's diff cells: 500 changed lines fill all five19 unmodified lines if !topics.is_empty() { chips(&mut c, theme, topics, y + 22.0, theme.accent); } let mut stats = vec![]; for (count, word) in [(open_tickets, "open tickets"), (open_pulls, "open pulls")] { stats.push((count.to_string(), word.to_string())); for (count, word) in [(open_tickets, "open ticket"), (open_pulls, "open pull")] { stats.push((count.to_string(), plural(*count, word))); } let at = match languages.is_empty() { true => FOOT, false => {24 unmodified lines Some(number), heading, 2, ); let y = meta(&mut c, theme, Some(state), &format!("{author} · {date}"), y); let y = meta( &mut c, theme, (state, state_fill(theme, state)), &[author, date], y, ); lines(&mut c, theme, body, y + 20.0, 960.0); kaomoji(&mut c, theme, "(°▽°)"); } Card::Pull {28 unmodified lines Some(number), heading, 2, ); let branches = format!("{from} → {into}"); let y = meta( &mut c, theme, Some(state), &format!("{author} · {from} → {into} · {date}"), (state, state_fill(theme, state)), &[author, &branches, date], y, ); lines(&mut c, theme, body, y + 20.0, 960.0); if let Some(diff) = diff {13 unmodified lines } => { frame(&mut c, theme, false); draw_ribbon(&mut c, theme, ribbon, Some("commit")); let y = title(&mut c, theme, Face::SansBold, 60.0, None, subject, 2); let line = format!("{author} · {date} · {branch}"); let top = y + 20.0; let baseline = Face::Mono.baseline(26.0, top, 30.0); let end = c.text(Face::MonoBold, 25.0, LEFT, baseline, short, theme.accent); let room = RIGHT - end - 18.0; c.text( Face::Mono, 26.0, end + 18.0, baseline, &Face::Mono.fit(26.0, room, &line), theme.muted, let y = meta( &mut c, theme, (short, theme.accent), &[author, date, branch], y, ); lines(&mut c, theme, body, top + 30.0 + 20.0, 960.0); lines(&mut c, theme, body, y + 20.0, 960.0); if let Some(diff) = diff { diffstat(&mut c, theme, diff); } kaomoji(&mut c, theme, "(•_•)");36 unmodified lines let size = 58.0; let name = Face::MonoBold.fit(size, RIGHT - x, handle); let baseline = Face::MonoBold.baseline(size, top + 18.0, size * 1.1); c.text(Face::MonoBold, size, x, baseline, &name, theme.text); let about_top = top + 18.0 + size * 1.1 + 10.0; let about_lines = Face::Sans.wrap(30.0, RIGHT - x, RIGHT - x, 2, about); for (index, line) in about_lines.iter().enumerate() { let baseline = Face::Sans.baseline( 30.0, top + 18.0 + size * 1.1 + 10.0 + index as f32 * 40.5, 40.5, ); let baseline = Face::Sans.baseline(30.0, about_top + index as f32 * 40.5, 40.5); c.text(Face::Sans, 30.0, x, baseline, line, theme.muted); } let below = (top + tile).max(about_top + about_lines.len() as f32 * 40.5); chips(&mut c, theme, repos, top + tile + 30.0, theme.accent); chips(&mut c, theme, repos, below + 30.0, theme.accent); counts( &mut c, theme, &[(repo_count.to_string(), "repos".to_string())], &[(repo_count.to_string(), plural(*repo_count, "repo"))], FOOT, ); kaomoji(&mut c, theme, "(=^ω^=)"); }28 unmodified lines c.fill(bounds, edge, shape(0.0, patch)); c.fill(bounds, inside, shape(BORDER, patch));}
/// a ribbon segment: its text, face, colour and filltype Segment = (String, Face, Rgb, Rgb);
/// the knot, then the owner or the repo, then what the page is, like svfe's ribbonsfn draw_ribbon(c: &mut Canvas, theme: &Theme, ribbon: &Ribbon, kind: Option<&str>) { let size = 25.0; let pad = 16.0; let mut segments: Vec<(String, Face, Rgb, Rgb)> = vec![( let (size, pad) = (25.0, 16.0); let mut segments: Vec<Segment> = vec![( ribbon.host.clone(), Face::MonoBold, theme.card, theme.accent,13 unmodified lines if !ribbon.path.is_empty() { let room = RIGHT - LEFT - fixed - 2.0 * pad - 2.0; segments[1].0 = Face::MonoBold.fit(size, room, &ribbon.path); } strip(c, theme, &segments, RIBBON_TOP, RIBBON_HEIGHT, size, pad);}
/// segments side by side from `top`, a rule between them and a border around. returns where it endsfn strip( c: &mut Canvas, theme: &Theme, segments: &[Segment], top: f32, height: f32, size: f32, pad: f32,) -> f32 { let mut x = LEFT; let baseline = Face::Mono.baseline(size, RIBBON_TOP, RIBBON_HEIGHT); let baseline = Face::Mono.baseline(size, top, height); for (index, (text, face, colour, fill)) in segments.iter().enumerate() { let width = face.width(size, text) + 2.0 * pad; c.rect(x, RIBBON_TOP, width, RIBBON_HEIGHT, *fill); c.rect(x, top, width, height, *fill); c.text(*face, size, x + pad, baseline, text, *colour); x += width; if index + 1 < segments.len() { c.rect(x, RIBBON_TOP, 2.0, RIBBON_HEIGHT, theme.border); c.rect(x, top, 2.0, height, theme.border); x += 2.0; } } c.outline( LEFT - 2.0, RIBBON_TOP - 2.0, top - 2.0, x - LEFT + 4.0, RIBBON_HEIGHT + 4.0, height + 4.0, 2.0, theme.border, ); top + height}
/// the page's meta as a ribbon from `top`, `lead` its filled segment. past the edge the/// segments drop from the end, and the last one left is cut to fit. returns where it endsfn meta(c: &mut Canvas, theme: &Theme, lead: (&str, Rgb), rest: &[&String], top: f32) -> f32 { let (size, pad) = (24.0, 14.0); let width = |face: Face, text: &str| face.width(size, text) + 2.0 * pad + 2.0; let mut segments: Vec<Segment> = vec![(lead.0.to_string(), Face::MonoBold, theme.card, lead.1)]; segments.extend( rest.iter() .map(|text| (text.to_string(), Face::Mono, theme.muted, theme.soft)), ); let total = |segments: &[Segment]| -> f32 { segments .iter() .map(|(text, face, ..)| width(*face, text)) .sum() }; while segments.len() > 2 && total(&segments) > RIGHT - LEFT { segments.pop(); } if let [first, last] = segments.as_mut_slice() { let room = RIGHT - LEFT - width(first.1, &first.0) - 2.0 * pad - 2.0; last.0 = Face::Mono.fit(size, room, &last.0); } strip(c, theme, &segments, top + 22.0, 40.0, size, pad)}
fn state_fill(theme: &Theme, state: &str) -> Rgb { match state { "open" => theme.teal, "merged" | "completed" => theme.lav, _ => theme.muted, }}
/// `word` with an s unless there's onefn plural(count: usize, word: &str) -> String { match count { 1 => word.to_string(), _ => format!("{word}s"), }}
/// the title in up to `rows` lines under the ribbon, `number` in the accent before it./// returns where it endsfn title( c: &mut Canvas,24 unmodified lines } top + wrapped.len().max(1) as f32 * line_height}
/// the state pill and a muted line after it, from `top`. returns where it endsfn meta(c: &mut Canvas, theme: &Theme, state: Option<&String>, line: &str, top: f32) -> f32 { let top = top + 20.0; let height = 39.0; let baseline = Face::Mono.baseline(26.0, top, height); let mut x = LEFT; if let Some(state) = state { let width = Face::MonoBold.width(23.0, state) + 28.0; let fill = match state.as_str() { "open" => theme.teal, "merged" | "completed" => theme.lav, _ => theme.muted, }; c.rect(x, top, width, height, fill); c.text(Face::MonoBold, 23.0, x + 14.0, baseline, state, theme.card); x += width + 18.0; } c.text( Face::Mono, 26.0, x, baseline, &Face::Mono.fit(26.0, RIGHT - x, line), theme.muted, ); top + height}
/// muted prose in up to two lines from `top`. returns where it endsfn lines(c: &mut Canvas, theme: &Theme, text: &str, top: f32, max: f32) -> f32 { let line_height = 40.5; let wrapped = Face::Sans.wrap(30.0, max, max, 2, text);167 unmodified linesExpand all if merged { notched.insert(HOLE_SLOTS - 1); } let radius = 10.0; let y = FRAME.1 + 22.0; let y = FRAME.1 + BORDER + radius - 2.0; let step = (RIGHT - LEFT - 2.0 * radius) / (HOLE_SLOTS - 1) as f32; for slot in 0..HOLE_SLOTS { let x = LEFT + radius + slot as f32 * step; let rim = match slot == HOLE_SLOTS - 1 {20 unmodified lines }); }}
/// the author's stripe down the left edge, fading out in svfe's rail dither/// the author's stripe down the left edgefn rail(c: &mut Canvas, colour: Rgb) { let (x0, y0, _, y1) = FRAME; let inside = shape(BORDER, true); let (left, top, bottom) = (x0 + BORDER, y0 + BORDER, y1 - BORDER - BAND); let stripe = ((left, top), (left + 8.0, bottom)); c.fill(stripe, colour, |point| { boxed(stripe, point).max(inside(point)) }); let mut y = top; while y < bottom { for (cx, cy, alpha) in RAIL_CELLS { let (px, py) = (left + 8.0 + cx * SCALE, y + cy * SCALE); let cell = ((px, py), (px + 2.0 * SCALE, (py + 2.0 * SCALE).min(bottom))); for row in (py as i32)..(cell.1.1 as i32) { for column in (px as i32)..((px + 2.0 * SCALE) as i32) { if inside((column as f32 + 0.5, row as f32 + 0.5)) < 0.0 { c.blend(column, row, colour, alpha); } } } } y += 8.0 * SCALE; }}
/// the owner's first letter on a soft tile, for an owner without an avatarfn letter_tile(c: &mut Canvas, theme: &Theme, handle: &str, x: f32, y: f32, size: f32) {37 unmodified lines23 unmodified lines2425262728282930313230 unmodified lines636465666768697071727374757677787980818283848586879 unmodified lines97989910084101102103104105106 unmodified linesExpand all23 unmodified lines name: "hydrant".into(), description: "very fast at protocol indexer with flexible filtering, xrpc queries, cursor-backed event stream, and more, built on fjall".into(), topics: vec!["rust".into(), "fjall".into(), "at-protocol".into()], open_tickets: 10, open_pulls: 2, open_pulls: 1, branch: "main".into(), languages: vec![ Language { name: "Rust".into(), size: 940 }, Language { name: "TypeScript".into(), size: 30 },30 unmodified lines diff: diff.clone(), }, ), ( "pull-open", Card::Pull { ribbon: ribbon(), number: "41".into(), title: "refetch budget: spend the hourly allowance evenly".into(), state: "open".into(), author: "@ptr.pet".into(), hue: 0, seed: "41".into(), date: "2026-11-11".into(), from: "refetch-budget-v1-with-a-long-branch-name".into(), into: "main".into(), body: "spreads host refetches over the hour instead of front-loading them".into(), diff: diff.clone(), }, ), ( "commit", Card::Commit { ribbon: ribbon(), subject: "[backfill] walk the mst while a filtered car streams".into(),9 unmodified lines "user", Card::User { host: "git.klbr.net".into(), handle: "@ptr.pet".into(), about: "writes indexers and git things".into(), about: "swe @tangled.org, writes indexers, knots and the odd yarn ball, mostly in rust and mostly at night".into(), repos: vec!["hydrant".into(), "svfe".into()], repo_count: 2, avatar: Some(vec![200; (TILE * TILE * 3) as usize]), },106 unmodified linesExpand all