feat(web): a denser sky, every change and run a mark; short links; the name above (refs #157) df5c69cbd047
Your sky reads its own events: a push draws a dot for each change it brought (up to 40), Flow runs are small squares, and proposal and issue events, releases join them, up to 1,500 events and 3,000 marks from the last 30 days. Busy repositories get more orbit lanes. Only events that happened one after another and sit within 70 px are joined, so a busy cluster reads as a constellation; a resting cursor's name sits above the cluster's outer ring. The spec records what was built.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 18:15 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
df5c69cbd047129e97727224870f5c9b49f5d634eccb37639188fc7d739906d4- Depends on
- 17ca6cddc534 25ab8c73514a 2f14c5f39e81 d23464c0ac60
- Depended on by
- 4dfa4599b3f7 3b1bc27fe805 ea14c4ec5c3f 5d90e0e045e2 2a0c09c09716 da8f7826a645 6246d1e51f4f 0a3b244b748f 6907a989e82a
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 49 atoms, 0 tree operations, 8 files
Browse the tree at this change
replaced assets/backdrop/backdrop_bg.wasm +0 −0
No line changes.
modified crates/backdrop/src/gl.rs +3 −1
| … | 45 unchanged lines |
||
| 46 | 46 | d = abs(length(uv) - 0.72) - 0.28; // an issue: a ring |
|
| 47 | 47 | } else if (vshape < 3.5) { |
|
| 48 | 48 | d = min(a.x + 3.5 * a.y, 3.5 * a.x + a.y) * 0.5 - 0.5; // a release: a star |
|
| 49 | + | } else if (vshape < 4.5) { |
|
| 50 | + | d = abs(length(uv) - 0.85) - 0.12; // a repository: a thin ring |
|
| 49 | 51 | } else { |
|
| 50 | - | d = abs(length(uv) - 0.85) - 0.12; // a repository: a thin ring |
|
| 52 | + | d = max(a.x, a.y) * 1.15 - 1.0; // a Flow run: a square |
|
| 51 | 53 | } |
|
| 52 | 54 | float cover = 1.0 - smoothstep(-px, px, d); |
|
| 53 | 55 | frag = vcolour * cover; |
|
| … | 142 unchanged lines |
modified crates/backdrop/src/lib.rs +7 −5
| … | 109 unchanged lines |
||
| 110 | 110 | } |
|
| 111 | 111 | } |
|
| 112 | 112 | ||
| 113 | - | /// The name beside a resting cursor (§4): faded in and out by CSS. |
|
| 113 | + | /// The name above the cluster a cursor rests by (§4): faded in and out |
|
| 114 | + | /// by CSS, which centres it on `left` and sets its foot on `top`. |
|
| 114 | 115 | fn show_label(&mut self) { |
|
| 115 | 116 | let next = self.sky.label().map(|(name, x, y)| (name.to_string(), x, y)); |
|
| 116 | 117 | let style = self.label.style(); |
|
| … | 3 unchanged lines |
||
| 120 | 121 | self.label.set_text_content(Some(&name)); |
|
| 121 | 122 | self.label_text = name; |
|
| 122 | 123 | } |
|
| 123 | - | let _ = style.set_property("left", &format!("{}px", (x + 12.0) as i32)); |
|
| 124 | - | let _ = style.set_property("top", &format!("{}px", (y - 8.0) as i32)); |
|
| 124 | + | let _ = style.set_property("left", &format!("{}px", x as i32)); |
|
| 125 | + | let _ = style.set_property("top", &format!("{}px", y as i32)); |
|
| 125 | 126 | let _ = style.set_property("opacity", "1"); |
|
| 126 | 127 | } |
|
| 127 | 128 | None => { |
|
| … | 19 unchanged lines |
||
| 147 | 148 | js_sys::Reflect::get(v, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED) |
|
| 148 | 149 | } |
|
| 149 | 150 | ||
| 150 | - | /// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms]]}`. |
|
| 151 | + | /// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms, n]]}`. |
|
| 151 | 152 | fn read_data(data: &JsValue) -> (Vec<Repo>, Vec<Event>) { |
|
| 152 | 153 | let now = get(data, "now").as_f64().unwrap_or(0.0); |
|
| 153 | 154 | let repos: Vec<Repo> = |
|
| … | 5 unchanged lines |
||
| 159 | 160 | let repo = e.get(0).as_f64()? as usize; |
|
| 160 | 161 | let kind = Kind::from_code(e.get(1).as_f64()? as u32); |
|
| 161 | 162 | let at = e.get(2).as_f64()?; |
|
| 162 | - | Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32 }) |
|
| 163 | + | let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32; |
|
| 164 | + | Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32, n }) |
|
| 163 | 165 | }) |
|
| 164 | 166 | .collect(); |
|
| 165 | 167 | (repos, events) |
|
| … | 125 unchanged lines |
modified crates/backdrop/src/sky.rs +50 −20
| … | 17 unchanged lines |
||
| 18 | 18 | pub const MAX_SPEED: f32 = 12.0; |
|
| 19 | 19 | /// How far each depth layer shifts against the cursor, front to back. |
|
| 20 | 20 | const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0]; |
|
| 21 | + | /// The longest link: only marks that happened one after another and sit |
|
| 22 | + | /// near each other are joined, so a busy repository reads as a |
|
| 23 | + | /// constellation, not a scribble. |
|
| 24 | + | const LINK_MAX: f32 = 70.0; |
|
| 21 | 25 | /// Links fade when the cursor has rested this long, in seconds. |
|
| 22 | 26 | const LINK_REST: f32 = 2.0; |
|
| 23 | 27 | /// A resting cursor this near a cluster's core names it, after this long. |
|
| … | 1 unchanged lines |
||
| 25 | 29 | const LABEL_REST: f32 = 0.4; |
|
| 26 | 30 | /// Events older than this many days are not drawn. |
|
| 27 | 31 | pub const DAYS: f32 = 30.0; |
|
| 32 | + | /// The most marks one push makes (one a change), and in all. |
|
| 33 | + | const PER_PUSH: u32 = 40; |
|
| 34 | + | const MAX_MARKS: usize = 3000; |
|
| 28 | 35 | ||
| 29 | 36 | /// What a mark stands for, which decides its shape. |
|
| 30 | 37 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| … | 2 unchanged lines |
||
| 33 | 40 | Proposal, |
|
| 34 | 41 | Issue, |
|
| 35 | 42 | Release, |
|
| 43 | + | /// A Flow run. |
|
| 44 | + | Run, |
|
| 36 | 45 | /// The repository itself, its cluster's core. |
|
| 37 | 46 | Repo, |
|
| 38 | 47 | } |
|
| 39 | 48 | ||
| 40 | 49 | impl Kind { |
|
| 41 | - | /// The code the page's data uses: 0 push, 1 proposal, 2 issue, |
|
| 42 | - | /// 3 release. |
|
| 50 | + | /// The code the page's data uses: 0 a push's change, 1 a proposal |
|
| 51 | + | /// event, 2 an issue event, 3 a release, 4 a Flow run. |
|
| 43 | 52 | pub fn from_code(code: u32) -> Kind { |
|
| 44 | 53 | match code { |
|
| 45 | 54 | 1 => Kind::Proposal, |
|
| 46 | 55 | 2 => Kind::Issue, |
|
| 47 | 56 | 3 => Kind::Release, |
|
| 57 | + | 4 => Kind::Run, |
|
| 48 | 58 | _ => Kind::Push, |
|
| 49 | 59 | } |
|
| 50 | 60 | } |
|
| 51 | 61 | ||
| 52 | 62 | /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring, |
|
| 53 | - | /// 3 four-point star, 4 thin ring (a repository). |
|
| 63 | + | /// 3 four-point star, 4 thin ring (a repository), 5 square. |
|
| 54 | 64 | pub fn shape(self) -> f32 { |
|
| 55 | 65 | match self { |
|
| 56 | 66 | Kind::Push => 0.0, |
|
| … | 1 unchanged lines |
||
| 58 | 68 | Kind::Issue => 2.0, |
|
| 59 | 69 | Kind::Release => 3.0, |
|
| 60 | 70 | Kind::Repo => 4.0, |
|
| 71 | + | Kind::Run => 5.0, |
|
| 61 | 72 | } |
|
| 62 | 73 | } |
|
| 63 | 74 | ||
| 64 | 75 | /// Its radius in CSS pixels. |
|
| 65 | 76 | fn size(self) -> f32 { |
|
| 66 | 77 | match self { |
|
| 67 | - | Kind::Push => 2.2, |
|
| 78 | + | Kind::Push => 2.0, |
|
| 79 | + | Kind::Run => 2.2, |
|
| 68 | 80 | Kind::Proposal => 3.2, |
|
| 69 | 81 | Kind::Issue => 3.0, |
|
| 70 | 82 | Kind::Release => 4.2, |
|
| … | 9 unchanged lines |
||
| 80 | 92 | } |
|
| 81 | 93 | ||
| 82 | 94 | /// One event, as the page gives it: its repository (an index into the |
|
| 83 | - | /// repositories), its kind and how old it is. |
|
| 95 | + | /// repositories), its kind, how old it is, and how many marks it makes (a |
|
| 96 | + | /// push's changes; one otherwise). |
|
| 84 | 97 | #[derive(Clone, Copy, Debug)] |
|
| 85 | 98 | pub struct Event { |
|
| 86 | 99 | pub repo: usize, |
|
| 87 | 100 | pub kind: Kind, |
|
| 88 | 101 | pub age_days: f32, |
|
| 102 | + | pub n: u32, |
|
| 89 | 103 | } |
|
| 90 | 104 | ||
| 91 | 105 | /// A repository's place: a fraction of the view, and a depth layer. |
|
| … | 105 unchanged lines |
||
| 197 | 211 | for i in 0..clusters.len() { |
|
| 198 | 212 | marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 }); |
|
| 199 | 213 | } |
|
| 214 | + | // Each event's marks: a push is a dot for each change it brought. |
|
| 215 | + | let expanded: Vec<Event> = events |
|
| 216 | + | .iter() |
|
| 217 | + | .filter(|e| e.repo < repos.len() && e.age_days <= DAYS) |
|
| 218 | + | .flat_map(|e| std::iter::repeat_n(*e, if e.kind == Kind::Push { e.n.clamp(1, PER_PUSH) as usize } else { 1 })) |
|
| 219 | + | .take(MAX_MARKS) |
|
| 220 | + | .collect(); |
|
| 221 | + | // A busy repository gets more lanes, so its marks spread out. |
|
| 222 | + | let mut count = vec![0u32; repos.len()]; |
|
| 223 | + | for e in &expanded { |
|
| 224 | + | count[e.repo] += 1; |
|
| 225 | + | } |
|
| 200 | 226 | let mut seen = vec![0u32; repos.len()]; |
|
| 201 | - | for e in events { |
|
| 202 | - | if e.repo >= repos.len() || e.age_days > DAYS { |
|
| 203 | - | continue; |
|
| 204 | - | } |
|
| 227 | + | for e in &expanded { |
|
| 205 | 228 | let k = seen[e.repo]; |
|
| 206 | 229 | seen[e.repo] += 1; |
|
| 230 | + | let lanes = (6 + count[e.repo] / 30).min(14); |
|
| 207 | 231 | let h = hash(&format!("{}#{k}", repos[e.repo].name)); |
|
| 208 | 232 | // Each repository turns one way; its marks keep their lanes, so |
|
| 209 | 233 | // two clusters' streams cross without touching. |
|
| 210 | 234 | let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 }; |
|
| 211 | - | let radius = 22.0 + (k % 6) as f32 * 11.0 + unit(h, 0) * 6.0; |
|
| 235 | + | let radius = 20.0 + (k % lanes) as f32 * 9.0 + unit(h, 0) * 6.0; |
|
| 212 | 236 | let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); |
|
| 213 | 237 | marks.push(Mark { |
|
| 214 | 238 | cluster: e.repo, |
|
| … | 118 unchanged lines |
||
| 333 | 357 | lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order)); |
|
| 334 | 358 | for pair in lit.windows(2) { |
|
| 335 | 359 | let (a, b) = (self.position(pair[0]), self.position(pair[1])); |
|
| 336 | - | if (a.0 - b.0).hypot(a.1 - b.1) > LIGHT_RADIUS * 1.2 { |
|
| 360 | + | if (a.0 - b.0).hypot(a.1 - b.1) > LINK_MAX { |
|
| 337 | 361 | continue; |
|
| 338 | 362 | } |
|
| 339 | 363 | let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade; |
|
| … | 1 unchanged lines |
||
| 341 | 365 | out.extend_from_slice(&[a.0, a.1, col[0], col[1], col[2], col[3], b.0, b.1, col[0], col[1], col[2], col[3]]); |
|
| 342 | 366 | } |
|
| 343 | 367 | } |
|
| 368 | + | } |
|
| 369 | + | ||
| 370 | + | /// How far a cluster's marks reach from its core. |
|
| 371 | + | fn reach(&self, c: usize) -> f32 { |
|
| 372 | + | self.marks.iter().filter(|m| m.cluster == c).map(|m| m.radius).fold(0.0, f32::max) |
|
| 344 | 373 | } |
|
| 345 | 374 | ||
| 346 | - | /// The name to show beside a resting cursor, and where: the nearest |
|
| 347 | - | /// cluster core within reach. |
|
| 375 | + | /// The name to show for a resting cursor, and where: centred just |
|
| 376 | + | /// above the nearest cluster within reach, clear of its marks. |
|
| 348 | 377 | pub fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 349 | 378 | let (x, y) = self.cursor?; |
|
| 350 | 379 | if self.rested < LABEL_REST { |
|
| … | 4 unchanged lines |
||
| 355 | 384 | .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y))) |
|
| 356 | 385 | .filter(|t| t.3 <= LABEL_NEAR) |
|
| 357 | 386 | .min_by(|a, b| a.3.total_cmp(&b.3)) |
|
| 358 | - | .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy)) |
|
| 387 | + | .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy - self.reach(i) - 10.0)) |
|
| 359 | 388 | } |
|
| 360 | 389 | } |
|
| 361 | 390 | ||
| … | 30 unchanged lines |
||
| 392 | 421 | fn sky(still: bool) -> Sky { |
|
| 393 | 422 | let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }]; |
|
| 394 | 423 | let events = vec![ |
|
| 395 | - | Event { repo: 0, kind: Kind::Push, age_days: 0.0 }, |
|
| 396 | - | Event { repo: 0, kind: Kind::Proposal, age_days: 2.0 }, |
|
| 397 | - | Event { repo: 1, kind: Kind::Release, age_days: 29.0 }, |
|
| 398 | - | Event { repo: 1, kind: Kind::Issue, age_days: 45.0 }, |
|
| 424 | + | Event { repo: 0, kind: Kind::Push, age_days: 0.0, n: 3 }, |
|
| 425 | + | Event { repo: 0, kind: Kind::Proposal, age_days: 2.0, n: 1 }, |
|
| 426 | + | Event { repo: 1, kind: Kind::Release, age_days: 29.0, n: 1 }, |
|
| 427 | + | Event { repo: 1, kind: Kind::Issue, age_days: 45.0, n: 1 }, |
|
| 399 | 428 | ]; |
|
| 400 | 429 | Sky::new(&repos, &events, 1600.0, 900.0, still) |
|
| 401 | 430 | } |
|
| … | 3 unchanged lines |
||
| 405 | 434 | let (a, b) = (sky(false), sky(false)); |
|
| 406 | 435 | assert_eq!(a.clusters[0].fx, b.clusters[0].fx); |
|
| 407 | 436 | assert_eq!(a.clusters[1].fy, b.clusters[1].fy); |
|
| 408 | - | // A core per repository, and one mark per event of the last 30 days. |
|
| 409 | - | assert_eq!(a.marks.len(), 2 + 3); |
|
| 437 | + | // A core per repository, a dot per change pushed, and one mark per |
|
| 438 | + | // other event of the last 30 days. |
|
| 439 | + | assert_eq!(a.marks.len(), 2 + 3 + 1 + 1); |
|
| 410 | 440 | assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn"); |
|
| 411 | 441 | let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap(); |
|
| 412 | 442 | let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap(); |
|
| … | 74 unchanged lines |
modified crates/hub/src/db/activity.rs +30 −0
| … | 134 unchanged lines |
||
| 135 | 135 | .fetch_all(pool) |
|
| 136 | 136 | .await |
|
| 137 | 137 | } |
|
| 138 | + | ||
| 139 | + | /// One event of Your sky (docs/backgrounds.md §5): its repository, when, |
|
| 140 | + | /// its kind (0 a push, 1 a proposal event, 2 an issue event, 3 a release, |
|
| 141 | + | /// 4 a Flow run) and how many marks it makes: a push's changes, else one. |
|
| 142 | + | #[derive(Clone, Debug, FromRow)] |
|
| 143 | + | pub struct SkyEvent { |
|
| 144 | + | pub repo_id: i64, |
|
| 145 | + | pub at_ms: i64, |
|
| 146 | + | pub kind: i64, |
|
| 147 | + | pub n: i64, |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | /// The events since `since_ms` in `repo_ids`, newest first, at most `limit`. |
|
| 151 | + | pub async fn sky(pool: &Pool, repo_ids: &[i64], since_ms: i64, limit: i64) -> sqlx::Result<Vec<SkyEvent>> { |
|
| 152 | + | sqlx::query_as( |
|
| 153 | + | "WITH seen(id) AS (SELECT value FROM json_each(?)), ev AS ( \ |
|
| 154 | + | SELECT repo_id, at_ms, 0 AS kind, MAX(changes, 1) AS n FROM pushes WHERE ok = 1 \ |
|
| 155 | + | UNION ALL SELECT p.repo_id, e.at_ms, 1, 1 FROM proposal_events e JOIN proposals p ON p.id = e.proposal_id \ |
|
| 156 | + | UNION ALL SELECT i.repo_id, e.at_ms, 2, 1 FROM issue_events e JOIN issues i ON i.id = e.issue_id \ |
|
| 157 | + | UNION ALL SELECT repo_id, created_ms, 3, 1 FROM releases \ |
|
| 158 | + | UNION ALL SELECT repo_id, created_ms, 4, 1 FROM flow_runs) \ |
|
| 159 | + | SELECT repo_id, at_ms, kind, n FROM ev WHERE repo_id IN (SELECT id FROM seen) AND at_ms >= ? \ |
|
| 160 | + | ORDER BY at_ms DESC LIMIT ?", |
|
| 161 | + | ) |
|
| 162 | + | .bind(ids_json(repo_ids)) |
|
| 163 | + | .bind(since_ms) |
|
| 164 | + | .bind(limit) |
|
| 165 | + | .fetch_all(pool) |
|
| 166 | + | .await |
|
| 167 | + | } |
modified crates/hub/src/web/home.rs +12 −18
| … | 58 unchanged lines |
||
| 59 | 59 | ||
| 60 | 60 | /// How many activity lines the dashboard shows. |
|
| 61 | 61 | const FEED_LIMIT: i64 = 40; |
|
| 62 | - | /// The most events Your sky draws, and how far back (docs/backgrounds.md §5). |
|
| 63 | - | const SKY_EVENTS: i64 = 400; |
|
| 62 | + | /// The most events Your sky reads, and how far back (docs/backgrounds.md §5). |
|
| 63 | + | const SKY_EVENTS: i64 = 1500; |
|
| 64 | 64 | const SKY_DAYS: i64 = 30; |
|
| 65 | 65 | ||
| 66 | 66 | /// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and |
|
| 67 | 67 | /// their events of the last 30 days, newest first, as |
|
| 68 | - | /// `{now, repos: [slug], events: [[repo, kind, at_ms]]}` with kinds 0 push, |
|
| 69 | - | /// 1 proposal, 2 issue, 3 release. Only the database is read, never a |
|
| 70 | - | /// repository's store; `<` is escaped so the JSON is safe in a script. |
|
| 71 | - | fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::Activity], now: i64) -> String { |
|
| 68 | + | /// `{now, repos: [slug], events: [[repo, kind, at_ms, n]]}` with kinds 0 a |
|
| 69 | + | /// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release, |
|
| 70 | + | /// 4 a Flow run. Only the database is read, never a repository's store; |
|
| 71 | + | /// `<` is escaped so the JSON is safe in a script. |
|
| 72 | + | fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64) -> String { |
|
| 72 | 73 | let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(); |
|
| 73 | - | let since = now - SKY_DAYS * 86_400_000; |
|
| 74 | 74 | let evs: Vec<serde_json::Value> = events |
|
| 75 | 75 | .iter() |
|
| 76 | - | .filter(|e| e.at_ms >= since) |
|
| 77 | 76 | .filter_map(|e| { |
|
| 78 | - | let repo = slugs.iter().position(|s| *s == format!("{}/{}", e.owner, e.name))?; |
|
| 79 | - | let kind = match e.kind.as_str() { |
|
| 80 | - | "proposal" => 1, |
|
| 81 | - | "issue" => 2, |
|
| 82 | - | "release" => 3, |
|
| 83 | - | _ => 0, |
|
| 84 | - | }; |
|
| 85 | - | Some(serde_json::json!([repo, kind, e.at_ms])) |
|
| 77 | + | let repo = mine.iter().position(|r| r.id == e.repo_id)?; |
|
| 78 | + | Some(serde_json::json!([repo, e.kind, e.at_ms, e.n])) |
|
| 86 | 79 | }) |
|
| 87 | 80 | .collect(); |
|
| 88 | 81 | serde_json::json!({ "now": now, "repos": slugs, "events": evs }).to_string().replace('<', "\\u003c") |
|
| … | 17 unchanged lines |
||
| 106 | 99 | let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?; |
|
| 107 | 100 | let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?; |
|
| 108 | 101 | let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect(); |
|
| 109 | - | let sky = db::activity::feed(&app.db, &mine_ids, SKY_EVENTS).await.map_err(db_err)?; |
|
| 110 | - | let backdrop = sky_json(&mine, &sky, db::now_ms()); |
|
| 102 | + | let now = db::now_ms(); |
|
| 103 | + | let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
|
| 104 | + | let backdrop = sky_json(&mine, &sky, now); |
|
| 111 | 105 | Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response()) |
|
| 112 | 106 | } |
|
| 113 | 107 | ||
| … | 209 unchanged lines |
modified docs/backgrounds.md +39 −29
| 1 | 1 | # Backdrops: living backgrounds for OxygenHub |
|
| 2 | 2 | ||
| 3 | - | Status: proposal, 2026-10-13 (oxygenhub#156). Nothing here is built yet; |
|
| 4 | - | the dashboard's scene comes first. |
|
| 3 | + | Status: the engine and the dashboard's Your sky are built (oxygenhub#157, |
|
| 4 | + | 2026-10-13); the other scenes are proposed (oxygenhub#156). |
|
| 5 | 5 | ||
| 6 | 6 | ## 1. What a backdrop is |
|
| 7 | 7 | ||
| … | 45 unchanged lines |
||
| 53 | 53 | ||
| 54 | 54 | | Token | Use | GitHub light | GitHub dark | Classic (dark) | |
|
| 55 | 55 | |---|---|---|---|---| |
|
| 56 | - | | `--bd-mark` | a resting mark | `--mute` at 16% | `--mute` at 22% | `--acc` at 18% | |
|
| 56 | + | | `--bd-mark` | a resting mark | `--mute` at 16% | `--mute` at 14% | `--acc` at 12% | |
|
| 57 | 57 | | `--bd-lit` | a mark near the cursor | `--acc` (#0969da) at 70% | brand cyan #00F0FF at 75% | #00F0FF at 85% | |
|
| 58 | 58 | | `--bd-link` | a line between lit marks | `--acc` at 22% | #00F0FF at 28% | #00F0FF at 35% | |
|
| 59 | 59 | ||
| … | 20 unchanged lines |
||
| 80 | 80 | out over 600 ms. Nothing is attracted or pushed, so the cursor never |
|
| 81 | 81 | rearranges the picture. |
|
| 82 | 82 | - **Lines mean something.** Among the lit marks, a faint line joins two |
|
| 83 | - | marks only when they belong together in the data: changes of the same |
|
| 84 | - | repository, or a change and the one it depends on where the scene has |
|
| 85 | - | dependencies. Lines fade as the cursor moves on or rests for 2 s. |
|
| 83 | + | marks only when they belong together in the data: events of the same |
|
| 84 | + | repository that happened one after another and sit within 70 px of |
|
| 85 | + | each other, so a busy repository reads as a constellation, not a |
|
| 86 | + | scribble. Lines fade as the cursor moves on or rests for 2 s. |
|
| 86 | 87 | - **Labels, sparingly.** When the cursor rests near a cluster, its name |
|
| 87 | - | fades in beside it in `--mute`, small. This is the only text a backdrop |
|
| 88 | + | fades in just above the cluster's outer ring in `--mute`, small. This is the only text a backdrop |
|
| 88 | 89 | draws, and it is never a link; the dashboard's own list is the link. |
|
| 89 | 90 | - **Parallax.** Three depth layers shift by at most 6 px, 3 px and 1 px |
|
| 90 | 91 | against the cursor's offset from the page's centre. That gives a little |
|
| … | 9 unchanged lines |
||
| 100 | 101 | activity feed from the database. The backdrop needs a little more of the |
|
| 101 | 102 | same: |
|
| 102 | 103 | ||
| 103 | - | - one record per repository you can see on the dashboard: its name and |
|
| 104 | - | its activity count in the last 30 days; |
|
| 105 | - | - up to 400 events from the last 30 days across those repositories |
|
| 106 | - | (pushes, proposals, issues, releases): each event's repository, kind |
|
| 107 | - | and time. |
|
| 104 | + | - the repositories in Your repositories, by name; |
|
| 105 | + | - up to 1,500 events from the last 30 days across them (pushes, |
|
| 106 | + | proposal and issue events, releases, Flow runs), each with its |
|
| 107 | + | repository, kind and time, and for a push the number of changes it |
|
| 108 | + | brought. A push draws one dot for each change, up to 40, and the sky |
|
| 109 | + | draws up to 3,000 marks. |
|
| 108 | 110 | ||
| 109 | 111 | The handler embeds these as JSON in the page (`<script |
|
| 110 | 112 | type="application/json" id="backdrop">`), so the backdrop costs no |
|
| … | 7 unchanged lines |
||
| 118 | 120 | - Each repository is a cluster, placed by its name's BLAKE3 on a golden |
|
| 119 | 121 | angle spiral, so your sky looks the same on every visit and on every |
|
| 120 | 122 | device. |
|
| 121 | - | - The most active repositories sit nearest the middle layer and the |
|
| 122 | - | quiet ones further back. |
|
| 123 | + | - The most active repositories sit in the front layer and the quiet ones |
|
| 124 | + | further back. |
|
| 123 | 125 | - A cluster's events are its marks: today's are brightest and a month |
|
| 124 | 126 | old are faint. |
|
| 125 | - | - Kinds differ by shape, not colour: a push is a dot, a proposal is a |
|
| 126 | - | small diamond, an issue is a ring and a release is a four-point star. |
|
| 127 | + | - Kinds differ by shape, not colour: a change pushed is a dot, a |
|
| 128 | + | proposal event a small diamond, an issue event a ring, a release a |
|
| 129 | + | four-point star, a Flow run a small square, and the repository itself a |
|
| 130 | + | thin ring at the cluster's core. |
|
| 131 | + | - A busy repository gets more orbit lanes (6 to 14), so its marks spread |
|
| 132 | + | out instead of piling up. |
|
| 127 | 133 | ||
| 128 | 134 | **Live.** When the page learns of new activity (it already polls for the |
|
| 129 | 135 | notification count), the event joins its cluster with an arrival ring |
|
| … | 1 unchanged lines |
||
| 131 | 137 | ||
| 132 | 138 | ## 6. Engine |
|
| 133 | 139 | ||
| 134 | - | One script, `assets/backdrop.js`, an ES module of about 15 KB with no |
|
| 135 | - | build step, served by the hub like `hub.js`: |
|
| 140 | + | The engine is Rust compiled to WebAssembly (Andy's choice, 2026-10-13): |
|
| 141 | + | `crates/backdrop`, a workspace of its own outside the hub's. `sky.rs` is |
|
| 142 | + | the scene with no browser in it, tested natively; `gl.rs` draws it; |
|
| 143 | + | `lib.rs` is the browser glue. `make backdrop` builds it for |
|
| 144 | + | `wasm32-unknown-unknown` and runs `wasm-bindgen --target web` into |
|
| 145 | + | `assets/backdrop/` (about 186 KB of wasm, 60 KB gzipped, and the glue), |
|
| 146 | + | which is committed and embedded in the hub, as `make ts` commits the API |
|
| 147 | + | types, so neither the hub build nor its offline CI needs the wasm |
|
| 148 | + | toolchain. `make backdrop-check` runs its fmt, clippy (wasm32) and tests. |
|
| 136 | 149 | ||
| 137 | - | - **Drawing.** WebGL2 with instanced quads for marks (one draw for all) |
|
| 138 | - | and one line buffer for links. If WebGL2 is unavailable, it falls back |
|
| 139 | - | to Canvas 2D; if that fails too, there is no backdrop and nothing |
|
| 150 | + | - **Drawing.** WebGL2 with instanced quads for marks (one draw for all, |
|
| 151 | + | each shape a signed distance in the fragment shader) and one line |
|
| 152 | + | buffer for links. Without WebGL2 there is no backdrop, and nothing |
|
| 140 | 153 | breaks. |
|
| 141 | 154 | - **The canvas.** One fixed full-viewport `<canvas aria-hidden="true">` |
|
| 142 | 155 | behind `main`, with `pointer-events: none`. Its device pixel ratio is |
|
| … | 10 unchanged lines |
||
| 153 | 166 | - **Scenes are data.** A scene is a preset of layout, shapes, counts and |
|
| 154 | 167 | which data it reads; the engine is shared. |
|
| 155 | 168 | ||
| 156 | - | **Why not Rust and WASM?** The rest of Oxygen is Rust, so it is a fair |
|
| 157 | - | question. But a scene of a few hundred marks is light work for a small |
|
| 158 | - | script, and WASM would add a second toolchain to the hub build and to |
|
| 159 | - | the offline CI image (wasm32 target, wasm-bindgen, web-sys vendored) for |
|
| 160 | - | no visible gain. What makes these backdrops new is that they show real |
|
| 161 | - | data. Revisit WASM if a scene ever needs heavy layout, such as a |
|
| 162 | - | repository's full change graph of thousands of changes, where sharing |
|
| 163 | - | oxy-core's own types would pay. |
|
| 169 | + | **Why Rust and WASM.** A small script would have done for a few hundred |
|
| 170 | + | marks; Rust keeps the whole of Oxygen in one language, lets the scene be |
|
| 171 | + | tested like the rest, and is ready for scenes with heavy layout (a |
|
| 172 | + | repository's full change graph) where sharing oxy-core's own types would |
|
| 173 | + | pay. The cost is kept out of the hub's build by committing the output. |
|
| 164 | 174 | ||
| 165 | 175 | ## 7. The viewer's choice |
|
| 166 | 176 | ||
| … | 23 unchanged lines |
modified templates/style.css +1 −1
| … | 1113 unchanged lines |
||
| 1114 | 1114 | the canvas shows in the margins and between the cards. */ |
|
| 1115 | 1115 | #backdrop { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; display: block; } |
|
| 1116 | 1116 | html:has(#backdrop) body { background: transparent; } |
|
| 1117 | - | .bd-label { position: fixed; z-index: -1; pointer-events: none; font-size: 12px; color: var(--mute); opacity: 0; transition: opacity .3s; white-space: nowrap; } |
|
| 1117 | + | .bd-label { position: fixed; z-index: -1; pointer-events: none; transform: translate(-50%, -100%); font-size: 12px; color: var(--mute); opacity: 0; transition: opacity .3s; white-space: nowrap; } |
|
| 1118 | 1118 | @media (prefers-reduced-motion: reduce) { .bd-label { transition: none; } } |