OXYGENHUB

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; } }