OXYGENHUB

Live arrivals on the sky da8f7826a645

New activity joins its cluster without a reload (docs/backgrounds.md §5
"Live"). GET /sky/arrivals?scene=mine|hub&since=MS answers what happened
since, over the same repositories as the sky it feeds: the viewer's member
repositories for mine (nothing signed out), the public ones for hub, never
a private repository in the hub's sky; private, no-store. The page polls it
every 30 s while shown; the engine adds the marks as the newest and draws
one ring in the lit colour, widening and fading over 1.2 s (none when
still). Tested like the sky's privacy: Andy, Bob and signed out.

Moving backdrops now stop 2 minutes after the last input, as §6 says:
every scene counted its drift as busy, so none ever stopped. Lighting,
links and rings keep a frame going while they change; an arrival wakes it.

closes #172
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 00:17 UTC
Signature
✔ signed by Andy McCall
Hash
da8f7826a645425ddb43d21ea15d154a00d29f50afe0f58684d1546f6ccb1d0c
Depends on
0a3b244b748f 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2f14c5f39e81 5501b1c657b2 6246d1e51f4f 6907a989e82a d23464c0ac60 d7d182467382 df5c69cbd047 eb56c8bc4d0e
Depended on by
8a48dd830178 a7aa76ac52d7 ffb67a81188c
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
42 atoms, 0 tree operations, 14 files

Browse the tree at this change

modified assets/backdrop/backdrop.js +10 −0
1 1
/**
2 +
 * New activity, as the page polled it (§5, "Live"): `{events: [[repo
3 +
 * name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring;
4 +
 * a scene other than the sky ignores it.
5 +
 * @param {any} data
6 +
 */
7 +
export function arrive(data) {
8 +
    wasm.arrive(data);
9 +
}
10 +
11 +
/**
2 12
 * Start a backdrop on `canvas` with the page's `data`: the scene it names
3 13
 * (docs/backgrounds.md §2), the dashboard's sky by default. `still`
4 14
 * is the viewer's choice of a still backdrop; reduced motion asks for
…
643 unchanged lines
replaced assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

modified crates/backdrop/src/adrift.rs +1 −1
…
95 unchanged lines
96 96
        None
97 97
    }
98 98
    fn busy(&self) -> bool {
99 -
        !self.field.still || self.field.easing()
99 +
        self.field.easing()
100 100
    }
101 101
    fn still(&self) -> bool {
102 102
        self.field.still
…
30 unchanged lines
modified crates/backdrop/src/lib.rs +30 −0
…
50 unchanged lines
51 51
52 52
type Frame = Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>>;
53 53
54 +
thread_local! {
55 +
    /// The running backdrop, for `arrive`.
56 +
    static RUNNING: RefCell<Option<(Rc<RefCell<Engine>>, Frame)>> = const { RefCell::new(None) };
57 +
}
58 +
54 59
impl Engine {
55 60
    fn now(&self) -> f64 {
56 61
        self.window.performance().map(|p| p.now()).unwrap_or(0.0)
…
141 unchanged lines
198 203
            Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) })
199 204
        })
200 205
        .collect()
206 +
}
207 +
208 +
/// New activity, as the page polled it (§5, "Live"): `{events: [[repo
209 +
/// name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring;
210 +
/// a scene other than the sky ignores it.
211 +
#[wasm_bindgen]
212 +
pub fn arrive(data: JsValue) {
213 +
    let Some((engine, frame)) = RUNNING.with(|r| r.borrow().clone()) else {
214 +
        return;
215 +
    };
216 +
    let mut any = false;
217 +
    // Oldest first, so the newest ends up newest.
218 +
    for v in js_sys::Array::from(&get(&data, "events")).iter().rev() {
219 +
        let e = js_sys::Array::from(&v);
220 +
        let (Some(repo), Some(kind)) = (e.get(0).as_string(), e.get(1).as_f64()) else {
221 +
            continue;
222 +
        };
223 +
        let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32;
224 +
        engine.borrow_mut().scene.arrive(&repo, Kind::from_code(kind as u32), n);
225 +
        any = true;
226 +
    }
227 +
    if any {
228 +
        wake(&engine, &frame);
229 +
    }
201 230
}
202 231
203 232
/// The scene the page's data names (`scene`), the sky when it names none.
…
145 unchanged lines
349 378
        shown.forget();
350 379
    }
351 380
381 +
    RUNNING.with(|r| *r.borrow_mut() = Some((engine.clone(), frame.clone())));
352 382
    wake(&engine, &frame);
353 383
    Ok(())
354 384
}
modified crates/backdrop/src/mark.rs +1 −1
…
114 unchanged lines
115 115
        None
116 116
    }
117 117
    fn busy(&self) -> bool {
118 -
        !self.field.still || self.field.easing()
118 +
        self.field.easing()
119 119
    }
120 120
    fn still(&self) -> bool {
121 121
        self.field.still
…
28 unchanged lines
modified crates/backdrop/src/ripples.rs +1 −1
…
189 unchanged lines
190 190
        self.field.label()
191 191
    }
192 192
    fn busy(&self) -> bool {
193 -
        !self.field.still || self.field.easing()
193 +
        self.field.easing()
194 194
    }
195 195
    fn still(&self) -> bool {
196 196
        self.field.still
…
47 unchanged lines
modified crates/backdrop/src/scene.rs +8 −2
1 1
//! A backdrop's scene (docs/backgrounds.md §2): what it draws and how it
2 2
//! moves. The engine (lib.rs) drives any of them the same way.
3 3
4 -
use crate::sky::Colours;
4 +
use crate::sky::{Colours, Kind};
5 5
6 6
pub trait Scene {
7 7
    /// The view is now `w` by `h` CSS pixels, the page's content column
…
9 unchanged lines
17 17
    fn links(&self, c: &Colours, out: &mut Vec<f32>);
18 18
    /// The name a resting cursor shows, and where.
19 19
    fn label(&self) -> Option<(&str, f32, f32)>;
20 -
    /// Whether a frame would show anything new.
20 +
    /// New activity in repository `repo` (§5, "Live"); only the sky shows it.
21 +
    fn arrive(&mut self, _repo: &str, _kind: Kind, _n: u32) {}
22 +
    /// Whether a frame would show anything new besides the drift, which
23 +
    /// the engine stops drawing a while after the last input (§6).
21 24
    fn busy(&self) -> bool;
22 25
    fn still(&self) -> bool;
23 26
    fn size(&self) -> (f32, f32);
…
18 unchanged lines
42 45
    fn label(&self) -> Option<(&str, f32, f32)> {
43 46
        crate::sky::Sky::label(self)
44 47
    }
48 +
    fn arrive(&mut self, repo: &str, kind: Kind, n: u32) {
49 +
        crate::sky::Sky::arrive(self, repo, kind, n)
50 +
    }
45 51
    fn busy(&self) -> bool {
46 52
        crate::sky::Sky::busy(self)
47 53
    }
…
7 unchanged lines
modified crates/backdrop/src/sky.rs +117 −4
…
39 unchanged lines
40 40
const MIN_MARGIN: f32 = 180.0;
41 41
/// The width a cluster gets in a margin's grid.
42 42
const CELL: f32 = 260.0;
43 +
/// An arrival's ring (§4): seconds it lasts, and its radius from first to
44 +
/// last, in CSS pixels before the zoom.
45 +
const RING_S: f32 = 1.2;
46 +
const RING_FROM: f32 = 6.0;
47 +
const RING_TO: f32 = 46.0;
43 48
44 49
/// What a mark stands for, which decides its shape.
45 50
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
…
106 unchanged lines
152 157
    pub bad: Rgba,
153 158
}
154 159
160 +
/// A widening ring at a cluster's core: something just arrived there.
161 +
#[derive(Clone, Copy, Debug)]
162 +
pub struct Ring {
163 +
    pub cluster: usize,
164 +
    /// Seconds since it began.
165 +
    pub age: f32,
166 +
}
167 +
155 168
/// The scene.
156 169
pub struct Sky {
157 170
    pub clusters: Vec<Cluster>,
…
13 unchanged lines
171 184
    avoid: Option<(f32, f32)>,
172 185
    parallax: [(f32, f32); 3],
173 186
    link_fade: f32,
187 +
    /// Arrivals' rings, while they widen.
188 +
    pub rings: Vec<Ring>,
189 +
    /// Marks added since the page loaded, for their places in the lanes.
190 +
    arrived: u32,
174 191
}
175 192
176 193
/// FNV-1a, then splitmix64's finaliser: a small, stable hash, so a
…
74 unchanged lines
251 268
                lit: 0.0,
252 269
            });
253 270
        }
254 -
        let mut sky =
255 -
            Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, avoid: None, parallax: [(0.0, 0.0); 3], link_fade: 0.0 };
271 +
        let mut sky = Sky {
272 +
            clusters,
273 +
            marks,
274 +
            w,
275 +
            h,
276 +
            cursor: None,
277 +
            rested: 0.0,
278 +
            still,
279 +
            avoid: None,
280 +
            parallax: [(0.0, 0.0); 3],
281 +
            link_fade: 0.0,
282 +
            rings: Vec::new(),
283 +
            arrived: 0,
284 +
        };
256 285
        sky.layout();
257 286
        sky
258 287
    }
…
65 unchanged lines
324 353
    pub fn point(&mut self, at: Option<(f32, f32)>) {
325 354
        self.cursor = at;
326 355
        self.rested = 0.0;
356 +
    }
357 +
358 +
    /// New activity in the repository `name` (§5, "Live"): its marks join
359 +
    /// its cluster as the newest, and one ring widens from its core. A
360 +
    /// repository the sky does not draw is ignored; a still sky adds the
361 +
    /// marks without the ring.
362 +
    pub fn arrive(&mut self, name: &str, kind: Kind, n: u32) {
363 +
        let Some(c) = self.clusters.iter().position(|cl| cl.name == name) else {
364 +
            return;
365 +
        };
366 +
        let add = if kind == Kind::Push { n.clamp(1, PER_PUSH) } else { 1 };
367 +
        for m in self.marks.iter_mut().filter(|m| m.cluster == c && m.kind != Kind::Repo) {
368 +
            m.order += add;
369 +
        }
370 +
        let have = self.marks.iter().filter(|m| m.cluster == c && m.kind != Kind::Repo).count() as u32;
371 +
        let lanes = (6 + (have + add) / 30).min(14);
372 +
        let dir = if hash(name) & 1 == 0 { 1.0 } else { -1.0 };
373 +
        for i in 0..add {
374 +
            let h = hash(&format!("{name}+{}", self.arrived));
375 +
            self.arrived += 1;
376 +
            let radius = (16.0 + ((have + i) % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
377 +
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
378 +
            self.marks.push(Mark { cluster: c, kind, order: add - 1 - i, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0, lit: 0.0 });
379 +
        }
380 +
        // Over the limit, the cluster's oldest marks make room.
381 +
        while self.marks.len() > MAX_MARKS {
382 +
            let Some(oldest) = self
383 +
                .marks
384 +
                .iter()
385 +
                .enumerate()
386 +
                .filter(|(_, m)| m.cluster == c && m.kind != Kind::Repo)
387 +
                .max_by_key(|(_, m)| m.order)
388 +
                .map(|(i, _)| i)
389 +
            else {
390 +
                break;
391 +
            };
392 +
            self.marks.swap_remove(oldest);
393 +
        }
394 +
        if !self.still {
395 +
            self.rings.retain(|r| r.cluster != c);
396 +
            self.rings.push(Ring { cluster: c, age: 0.0 });
397 +
        }
327 398
    }
328 399
329 400
    /// Where cluster `c`'s core is now.
…
13 unchanged lines
343 414
    pub fn step(&mut self, dt: f32) {
344 415
        let dt = dt.clamp(0.0, 0.1);
345 416
        self.rested += dt;
417 +
        for r in &mut self.rings {
418 +
            r.age += dt;
419 +
        }
420 +
        self.rings.retain(|r| r.age < RING_S);
346 421
        if !self.still {
347 422
            for m in &mut self.marks {
348 423
                m.angle = (m.angle + m.speed * dt) % TAU;
…
44 unchanged lines
393 468
        };
394 469
    }
395 470
396 -
    /// Whether anything is still changing: a frame is worth drawing.
471 +
    /// Whether anything besides the drift is still changing: lighting,
472 +
    /// links or a ring. The engine keeps drawing a moving sky for a while
473 +
    /// after the last input anyway (§6), then stops until something is.
397 474
    pub fn busy(&self) -> bool {
398 -
        !self.still || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
475 +
        !self.rings.is_empty() || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
399 476
    }
400 477
401 478
    /// One instance per mark: centre x, y and radius in CSS pixels, its
…
7 unchanged lines
409 486
            let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit);
410 487
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
411 488
        }
489 +
        // Arrivals: a thin ring in the lit colour, widening as it fades.
490 +
        for r in &self.rings {
491 +
            let t = (r.age / RING_S).clamp(0.0, 1.0);
492 +
            let (x, y) = self.core(r.cluster);
493 +
            let size = (RING_FROM + (RING_TO - RING_FROM) * (1.0 - (1.0 - t) * (1.0 - t))) * ZOOM;
494 +
            let a = c.lit[3] * (1.0 - t) * 0.8;
495 +
            out.extend_from_slice(&[x, y, size, Kind::Repo.shape(), c.lit[0] * a, c.lit[1] * a, c.lit[2] * a, a]);
496 +
        }
412 497
    }
413 498
414 499
    /// The links among lit marks: each repository's lit marks in the order
…
188 unchanged lines
603 688
            octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1;
604 689
        }
605 690
        assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}");
691 +
    }
692 +
693 +
    #[test]
694 +
    fn an_arrival_joins_its_cluster_with_one_ring() {
695 +
        let mut s = sky(false);
696 +
        let before = s.marks.iter().filter(|m| m.cluster == 1).count();
697 +
        s.arrive("andy/oxygen", Kind::Push, 2);
698 +
        s.arrive("someone/else", Kind::Issue, 1);
699 +
        let marks: Vec<&Mark> = s.marks.iter().filter(|m| m.cluster == 1).collect();
700 +
        assert_eq!(marks.len(), before + 2, "a dot for each change pushed; an unknown repository adds nothing");
701 +
        let newest: Vec<&&Mark> = marks.iter().filter(|m| m.kind == Kind::Push).collect();
702 +
        assert!(newest.iter().all(|m| m.order < 2 && m.bright == 1.0), "the newest, at full brightness");
703 +
        assert!(marks.iter().any(|m| m.kind == Kind::Release && m.order == 2), "older marks move back");
704 +
        assert_eq!(s.rings.len(), 1);
705 +
        assert!(s.busy(), "a ring is drawn while it widens");
706 +
        let colours = Colours { lit: [0.0, 0.4, 0.9, 0.7], ..Default::default() };
707 +
        let mut inst = Vec::new();
708 +
        s.instances(&colours, &mut inst);
709 +
        assert_eq!(inst.len(), (s.marks.len() + 1) * 8, "the ring is one more instance");
710 +
        for _ in 0..80 {
711 +
            s.step(1.0 / 60.0);
712 +
        }
713 +
        assert!(s.rings.is_empty() && !s.busy(), "gone after 1.2 s, and nothing left to draw but the drift");
714 +
        // A still sky takes the marks without the ring.
715 +
        let mut still = sky(true);
716 +
        still.arrive("andy/hub", Kind::Release, 1);
717 +
        assert!(still.rings.is_empty());
718 +
        assert_eq!(still.marks.iter().filter(|m| m.kind == Kind::Release).count(), 2);
606 719
    }
607 720
608 721
    #[test]
…
8 unchanged lines
modified crates/backdrop/src/swarm.rs +1 −1
…
167 unchanged lines
168 168
        self.field.label()
169 169
    }
170 170
    fn busy(&self) -> bool {
171 -
        !self.field.still || self.field.easing()
171 +
        self.field.easing()
172 172
    }
173 173
    fn still(&self) -> bool {
174 174
        self.field.still
…
30 unchanged lines
modified crates/hub/src/web/home.rs +60 −4
…
73 unchanged lines
74 74
/// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release,
75 75
/// 4 a Flow run. Only the database is read, never a repository's store;
76 76
/// `<` is escaped so the JSON is safe in a script.
77 -
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64) -> String {
77 +
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String {
78 78
    let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect();
79 79
    let evs: Vec<serde_json::Value> = events
80 80
        .iter()
…
2 unchanged lines
83 83
            Some(serde_json::json!([repo, e.kind, e.at_ms, e.n]))
84 84
        })
85 85
        .collect();
86 -
    serde_json::json!({ "now": now, "repos": slugs, "events": evs }).to_string().replace('<', "\\u003c")
86 +
    serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c")
87 87
}
88 88
89 +
/// The repositories a sky draws: `mine`, the viewer's own (member) ones,
90 +
/// as the dashboard's; `hub`, the public ones the viewer may see, as
91 +
/// Explore's. Never a private repository in the hub's sky.
92 +
async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result<Vec<db::repos::RepoRow>, Error> {
93 +
    let db_err = |e| Error::from_db(shell.clone(), e);
94 +
    let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
95 +
    match (scene, viewer.account()) {
96 +
        ("mine", Some(account)) => {
97 +
            let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
98 +
            Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect())
99 +
        }
100 +
        ("hub", _) => {
101 +
            let mut public: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| r.visibility == "public").collect();
102 +
            public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
103 +
            public.truncate(SKY_REPOS);
104 +
            Ok(public)
105 +
        }
106 +
        _ => Ok(Vec::new()),
107 +
    }
108 +
}
109 +
110 +
#[derive(Deserialize)]
111 +
pub struct ArrivalsQuery {
112 +
    #[serde(default)]
113 +
    scene: String,
114 +
    #[serde(default)]
115 +
    since: i64,
116 +
}
117 +
118 +
/// `GET /sky/arrivals?scene=mine|hub&since=MS` (docs/backgrounds.md §4,
119 +
/// "Arrivals"): what happened in a sky's repositories since `since`, by
120 +
/// repository name, for the page to add without reloading. The same
121 +
/// repositories as the sky it feeds, never more.
122 +
pub async fn arrivals(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ArrivalsQuery>) -> Result<Response, Error> {
123 +
    let shell = Shell::new(&app, "/sky/arrivals", &viewer);
124 +
    let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?;
125 +
    let now = db::now_ms();
126 +
    // At most a day back: a page left open longer starts again.
127 +
    let since = q.since.clamp(now - 86_400_000, now);
128 +
    let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
129 +
    let events = if ids.is_empty() {
130 +
        Vec::new()
131 +
    } else {
132 +
        db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?
133 +
    };
134 +
    let evs: Vec<serde_json::Value> = events
135 +
        .iter()
136 +
        .filter_map(|e| {
137 +
            let r = repos.iter().find(|r| r.id == e.repo_id)?;
138 +
            Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n]))
139 +
        })
140 +
        .collect();
141 +
    let body = serde_json::json!({ "now": now, "events": evs });
142 +
    Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response())
143 +
}
144 +
89 145
/// `/`: the dashboard when signed in, every visible repository otherwise.
90 146
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
91 147
    let Some(account) = viewer.account().cloned() else {
…
14 unchanged lines
106 162
    let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
107 163
    let now = db::now_ms();
108 164
    let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
109 -
    let backdrop = sky_json(&mine, &sky, now);
165 +
    let backdrop = sky_json(&mine, &sky, now, "mine");
110 166
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response())
111 167
}
112 168
…
31 unchanged lines
144 200
    let ids: Vec<i64> = public.iter().map(|r| r.id).collect();
145 201
    let now = db::now_ms();
146 202
    let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
147 -
    let backdrop = sky_json(&public, &sky, now);
203 +
    let backdrop = sky_json(&public, &sky, now, "hub");
148 204
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop }))
149 205
}
150 206
…
179 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
88 unchanged lines
89 89
        .route("/settings/keys/verify", post(settings::keys_verify))
90 90
        .route("/settings/keys/unlink", post(settings::keys_unlink))
91 91
        .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create))
92 +
        .route("/sky/arrivals", get(home::arrivals))
92 93
        .route("/settings/backups", get(backups::user).post(backups::user_save))
93 94
        .route("/settings/backups/run", post(backups::user_run))
94 95
        .route("/orgs/{name}/settings/backups", get(backups::org).post(backups::org_save))
…
656 unchanged lines
modified crates/hub/tests/issues.rs +23 −0
…
417 unchanged lines
418 418
        .unwrap();
419 419
    assert_eq!(res.headers()[header::CONTENT_TYPE], "application/wasm");
420 420
    assert!(res.into_body().collect().await.unwrap().to_bytes().starts_with(b"\0asm"));
421 +
    // Live arrivals (#172): what happened since, by the sky's own rule.
422 +
    assert!(sky_of(&page.body).contains(r#""live":"mine""#) && hub_sky.contains(r#""live":"hub""#));
423 +
    let since = db::now_ms() - 3_600_000;
424 +
    let arrivals = |res: Res| -> Vec<String> {
425 +
        assert_eq!(res.status, StatusCode::OK, "{}", res.body);
426 +
        let v: serde_json::Value = serde_json::from_str(&res.body).unwrap();
427 +
        v["events"].as_array().unwrap().iter().map(|e| e[0].as_str().unwrap().to_string()).collect()
428 +
    };
429 +
    let mine = arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&andy)).await);
430 +
    assert!(mine.iter().any(|r| r == "andy/demo") && mine.iter().any(|r| r == "andy/secret"), "{mine:?}");
431 +
    let hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), Some(&andy)).await);
432 +
    assert!(hub.iter().any(|r| r == "andy/demo") && !hub.iter().any(|r| r == "andy/secret"), "the hub's sky is public only: {hub:?}");
433 +
    let anon_hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), None).await);
434 +
    assert!(!anon_hub.iter().any(|r| r == "andy/secret"), "{anon_hub:?}");
435 +
    assert!(arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), None).await).is_empty(), "signed out has no `mine`");
436 +
    assert!(
437 +
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&bob)).await).is_empty(),
438 +
        "Bob's sky has none of Andy's"
439 +
    );
440 +
    assert!(
441 +
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={}", db::now_ms()), Some(&andy)).await).is_empty(),
442 +
        "nothing since now"
443 +
    );
421 444
422 445
    // Once he reviews one, it leaves his queue.
423 446
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
…
607 unchanged lines
modified docs/backgrounds.md +14 −4
…
139 unchanged lines
140 140
- A busy repository gets more orbit lanes (6 to 14), so its marks spread
141 141
  out instead of piling up.
142 142
143 -
**Live.** When the page learns of new activity (it already polls for the
144 -
notification count), the event joins its cluster with an arrival ring
145 -
(§4). Nothing reloads.
143 +
**Live.** Built (#172). The page's data names its sky (`"live": "mine"`
144 +
on the dashboard, `"hub"` on Explore and the signed-out home), and every
145 +
30 s while the page is shown it asks `GET /sky/arrivals?scene=…&since=MS`
146 +
what happened since (at most a day back). The answer is the same query
147 +
over the same repositories as the sky it feeds: the viewer's member
148 +
repositories for `mine` (nothing signed out), the public ones for `hub`,
149 +
never a private repository in the hub's sky; it is `private, no-store`.
150 +
Each event joins its cluster as the newest marks with an arrival ring
151 +
(§4), a thin ring in the lit colour widening from 6 to 46 px (times the
152 +
zoom) and fading over 1.2 s; a repository the sky does not draw is
153 +
ignored, and a still sky adds the marks without the ring. Nothing
154 +
reloads.
146 155
147 156
## 5.1 The other scenes
148 157
…
50 unchanged lines
199 208
- **Frame rate.** It runs at 60 fps while the cursor moves, eases to
200 209
  30 fps when the cursor is still, and pauses on `visibilitychange`.
201 210
  After 2 minutes without input it draws one last frame and stops until
202 -
  the next movement.
211 +
  the next movement or arrival (a scene's drift alone never keeps it
212 +
  running; lighting, links and rings do, while they change).
203 213
- **Reduced motion.** With `prefers-reduced-motion: reduce` it draws one
204 214
  still frame with no drift, parallax or arrival rings; lit marks still
205 215
  respond, instantly.
…
36 unchanged lines
modified templates/backdrop.html +21 −2
…
1 unchanged lines
2 2
   `#backdrop-data` names (written just before this include) drawn by the
3 3
   engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie;
4 4
   unset, a touch-only device or Save-Data gets a still sky. Any failure
5 -
   leaves no backdrop and nothing else changed. #}
5 +
   leaves no backdrop and nothing else changed. A sky with `live` polls
6 +
   `/sky/arrivals` for what happened since and adds it. #}
6 7
<canvas id="backdrop" aria-hidden="true"></canvas>
7 8
<script type="module">
8 9
const canvas = document.getElementById("backdrop");
…
6 unchanged lines
15 16
  try {
16 17
    const bd = await import("/static/backdrop/backdrop.js?v={{ shell.assets() }}");
17 18
    await bd.default({ module_or_path: "/static/backdrop/backdrop_bg.wasm?v={{ shell.assets() }}" });
18 -
    bd.start(canvas, JSON.parse(document.getElementById("backdrop-data").textContent), pick === "still");
19 +
    const data = JSON.parse(document.getElementById("backdrop-data").textContent);
20 +
    bd.start(canvas, data, pick === "still");
21 +
    // Live (§5): a sky that names its repositories (`mine` or `hub`) asks
22 +
    // every 30 s, while the page is shown, what happened since.
23 +
    if (data.live) {
24 +
      let since = data.now;
25 +
      const poll = async () => {
26 +
        if (document.hidden) return;
27 +
        try {
28 +
          const r = await fetch(`/sky/arrivals?scene=${data.live}&since=${since}`, { headers: { accept: "application/json" } });
29 +
          if (!r.ok) return;
30 +
          const got = await r.json();
31 +
          since = got.now;
32 +
          if (got.events.length) bd.arrive(got);
33 +
        } catch (e) {}
34 +
      };
35 +
      setInterval(poll, 30000);
36 +
      document.addEventListener("visibilitychange", poll);
37 +
    }
19 38
  } catch (e) {
20 39
    canvas.remove();
21 40
  }
…
2 unchanged lines