OXYGENHUB

feat(web): the mark, ripples, the swarm and adrift (closes #167) 6246d1e51f4f

The engine gains a Scene trait and a shared field (dots placed by their
scene each frame, lit, linked in groups, named on rest, in the backdrop
tokens or the page's status colours), and four scenes beside the sky:
the Oxygen mark's two rings turning opposite ways behind sign in, register
and device sign-in; ripples on a repository's Diffusion tab, its upstreams
flowing in and changes out to its consumers in their pills' tones, a
ripple widening every five seconds; the swarm on Admin › Runners › Images
(now standard width, for margins), chunks streaming to a fetching runner
from every holder; and one mark drifting from its cluster on a page not
found or a server error. Each page names its scene in #backdrop-data.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 20:18 UTC
Signature
✔ signed by Andy McCall
Hash
6246d1e51f4fdb99485662612d4148901d1456d34c65f0ecf80643da769d634e
Depends on
0a3b244b748f 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2f14c5f39e81 33bd0bd45f8a 42fa95540468 61ecbd299f22 6907a989e82a 6ca0b971ff07 757195ad7a5d b5668108487f b6b7e4cf6b24 d23464c0ac60 df5c69cbd047 df71c7892d27 eb56c8bc4d0e
Depended on by
737de0fac722 da8f7826a645 5501b1c657b2 f993f792537e
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
64 atoms, 6 tree operations, 25 files

Browse the tree at this change

added crates/backdrop/src/adrift.rs +141 −0
1 +
//! Adrift (docs/backgrounds.md §2): the not-found and server-error pages.
2 +
//! A quiet cluster in the margin, and one mark drifting away from it, as
3 +
//! the page drifted from what was asked for. It drifts for a minute, fades
4 +
//! and sets out again. No data.
5 +
6 +
use crate::field::{margins, unit, Dot, Field};
7 +
use crate::scene::Scene;
8 +
use crate::sky::{hash, Colours, MAX_SPEED};
9 +
use std::f32::consts::TAU;
10 +
11 +
const CLUSTER: usize = 70;
12 +
/// The lost mark's speed, and how long one drift lasts, fade included.
13 +
const DRIFT_SPEED: f32 = 7.0;
14 +
const DRIFT_S: f32 = 70.0;
15 +
const FADE_S: f32 = 5.0;
16 +
17 +
struct Orbit {
18 +
    radius: f32,
19 +
    angle: f32,
20 +
    speed: f32,
21 +
}
22 +
23 +
pub struct Adrift {
24 +
    field: Field,
25 +
    orbits: Vec<Orbit>,
26 +
    centre: (f32, f32),
27 +
    w: f32,
28 +
    h: f32,
29 +
    t: f32,
30 +
}
31 +
32 +
impl Adrift {
33 +
    pub fn new(still: bool) -> Adrift {
34 +
        let mut field = Field::new(still);
35 +
        let mut orbits = Vec::new();
36 +
        for k in 0..CLUSTER {
37 +
            let h = hash(&format!("adrift#{k}"));
38 +
            let radius = (14.0 + (k % 8) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
39 +
            orbits.push(Orbit { radius, angle: unit(h, 16) * TAU, speed: (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 32)) });
40 +
            let mut d = Dot::new(0.0, 0.0, 2.6, if k % 7 == 0 { 2.0 } else { 0.0 });
41 +
            d.bright = 0.5 + 0.5 * unit(h, 48);
42 +
            d.group = 0;
43 +
            d.order = k as u32;
44 +
            field.dots.push(d);
45 +
        }
46 +
        // The cluster's core, and the lost mark, a little larger.
47 +
        field.dots.push(Dot::new(0.0, 0.0, 8.0, 4.0));
48 +
        let mut lost = Dot::new(0.0, 0.0, 4.2, 0.0);
49 +
        lost.bright = 1.0;
50 +
        field.dots.push(lost);
51 +
        let mut a = Adrift { field, orbits, centre: (0.0, 0.0), w: 0.0, h: 0.0, t: 12.0 };
52 +
        a.place();
53 +
        a
54 +
    }
55 +
56 +
    fn place(&mut self) {
57 +
        let (cx, cy) = self.centre;
58 +
        for (i, o) in self.orbits.iter().enumerate() {
59 +
            let a = o.angle + o.speed * self.t;
60 +
            self.field.dots[i].x = cx + a.cos() * o.radius;
61 +
            self.field.dots[i].y = cy + a.sin() * o.radius;
62 +
        }
63 +
        let core = &mut self.field.dots[CLUSTER];
64 +
        core.x = cx;
65 +
        core.y = cy;
66 +
        // The lost mark leaves up and to the right, fading at the end of
67 +
        // each drift and setting out again from the cluster's edge.
68 +
        let p = self.t % DRIFT_S;
69 +
        let dir: f32 = -0.35;
70 +
        let from = 70.0 * 1.4;
71 +
        let lost = &mut self.field.dots[CLUSTER + 1];
72 +
        lost.x = cx + dir.cos() * (from + p * DRIFT_SPEED);
73 +
        lost.y = cy + dir.sin() * (from + p * DRIFT_SPEED);
74 +
        lost.bright = ((DRIFT_S - p) / FADE_S).min(p / FADE_S).clamp(0.0, 1.0);
75 +
    }
76 +
}
77 +
78 +
impl Scene for Adrift {
79 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
80 +
        self.w = w;
81 +
        self.h = h;
82 +
        self.centre = match margins(w, avoid) {
83 +
            Some((l, _)) => (l * 0.45, h * 0.62),
84 +
            None => (w * 0.15, h * 0.7),
85 +
        };
86 +
        self.place();
87 +
    }
88 +
    fn point(&mut self, at: Option<(f32, f32)>) {
89 +
        self.field.point(at);
90 +
    }
91 +
    fn step(&mut self, dt: f32) {
92 +
        if !self.field.still {
93 +
            self.t += dt.clamp(0.0, 0.1);
94 +
        }
95 +
        self.place();
96 +
        self.field.light(dt);
97 +
    }
98 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
99 +
        self.field.instances(c, out)
100 +
    }
101 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
102 +
        self.field.links(c, out)
103 +
    }
104 +
    fn label(&self) -> Option<(&str, f32, f32)> {
105 +
        None
106 +
    }
107 +
    fn busy(&self) -> bool {
108 +
        !self.field.still || self.field.easing()
109 +
    }
110 +
    fn still(&self) -> bool {
111 +
        self.field.still
112 +
    }
113 +
    fn size(&self) -> (f32, f32) {
114 +
        (self.w, self.h)
115 +
    }
116 +
}
117 +
118 +
#[cfg(test)]
119 +
mod tests {
120 +
    use super::*;
121 +
122 +
    #[test]
123 +
    fn one_mark_drifts_away_from_the_cluster_and_sets_out_again() {
124 +
        let mut a = Adrift::new(false);
125 +
        a.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
126 +
        assert!(a.centre.0 < 707.0, "the cluster sits in the margin");
127 +
        let dist = |a: &Adrift| {
128 +
            let l = &a.field.dots[CLUSTER + 1];
129 +
            (l.x - a.centre.0).hypot(l.y - a.centre.1)
130 +
        };
131 +
        let early = dist(&a);
132 +
        for _ in 0..200 {
133 +
            a.step(0.1);
134 +
        }
135 +
        assert!(dist(&a) > early + 100.0, "it drifts away");
136 +
        for _ in 0..500 {
137 +
            a.step(0.1);
138 +
        }
139 +
        assert!(dist(&a) < early + 100.0, "and sets out again");
140 +
    }
141 +
}
added crates/backdrop/src/field.rs +245 −0
1 +
//! What every scene but the sky shares (docs/backgrounds.md §4): a field
2 +
//! of dots that its scene places each frame, lit by the cursor without
3 +
//! being moved, joined within a group in order, and named things that a
4 +
//! resting cursor names. Dots of a scene with a status (Diffusion's pills)
5 +
//! take the page's status colours; every other dot the backdrop tokens.
6 +
7 +
use crate::sky::{Colours, Rgba};
8 +
9 +
/// How far the cursor lights dots, in CSS pixels, as in the sky.
10 +
pub const LIGHT_RADIUS: f32 = 170.0;
11 +
const LIT_IN: f32 = 0.15;
12 +
const LIT_OUT: f32 = 0.6;
13 +
/// The longest link drawn between two dots of a group.
14 +
const LINK_MAX: f32 = 85.0;
15 +
const LINK_REST: f32 = 2.0;
16 +
const LABEL_NEAR: f32 = 110.0;
17 +
const LABEL_REST: f32 = 0.4;
18 +
19 +
/// A dot's colour: the backdrop's own, or a status where the data has one.
20 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21 +
pub enum Tone {
22 +
    Mark,
23 +
    Ok,
24 +
    Warn,
25 +
    Bad,
26 +
}
27 +
28 +
impl Tone {
29 +
    /// A Diffusion pill half's tone as the page names it.
30 +
    pub fn from_name(s: &str) -> Tone {
31 +
        match s {
32 +
            "green" => Tone::Ok,
33 +
            "amber" => Tone::Warn,
34 +
            "red" => Tone::Bad,
35 +
            _ => Tone::Mark,
36 +
        }
37 +
    }
38 +
}
39 +
40 +
/// One drawn dot.
41 +
#[derive(Clone, Copy, Debug)]
42 +
pub struct Dot {
43 +
    pub x: f32,
44 +
    pub y: f32,
45 +
    /// Radius in CSS pixels.
46 +
    pub size: f32,
47 +
    /// The shader's shape (sky::Kind::shape).
48 +
    pub shape: f32,
49 +
    pub tone: Tone,
50 +
    /// 0 to 1: how strongly it shows at rest.
51 +
    pub bright: f32,
52 +
    pub lit: f32,
53 +
    /// Dots of one group are joined in `order` when lit; `NONE` never.
54 +
    pub group: u32,
55 +
    pub order: u32,
56 +
}
57 +
58 +
impl Dot {
59 +
    pub const NONE: u32 = u32::MAX;
60 +
61 +
    pub fn new(x: f32, y: f32, size: f32, shape: f32) -> Dot {
62 +
        Dot { x, y, size, shape, tone: Tone::Mark, bright: 1.0, lit: 0.0, group: Dot::NONE, order: 0 }
63 +
    }
64 +
}
65 +
66 +
/// Something with a name the cursor can rest by.
67 +
#[derive(Clone, Debug)]
68 +
pub struct Named {
69 +
    pub name: String,
70 +
    pub x: f32,
71 +
    pub y: f32,
72 +
    /// How far its dots reach: the label sits just above.
73 +
    pub reach: f32,
74 +
}
75 +
76 +
pub struct Field {
77 +
    pub dots: Vec<Dot>,
78 +
    pub named: Vec<Named>,
79 +
    pub cursor: Option<(f32, f32)>,
80 +
    pub rested: f32,
81 +
    pub still: bool,
82 +
    link_fade: f32,
83 +
}
84 +
85 +
impl Field {
86 +
    pub fn new(still: bool) -> Field {
87 +
        Field { dots: Vec::new(), named: Vec::new(), cursor: None, rested: 0.0, still, link_fade: 0.0 }
88 +
    }
89 +
90 +
    pub fn point(&mut self, at: Option<(f32, f32)>) {
91 +
        self.cursor = at;
92 +
        self.rested = 0.0;
93 +
    }
94 +
95 +
    /// Light the dots near the cursor, after the scene has placed them.
96 +
    pub fn light(&mut self, dt: f32) {
97 +
        let dt = dt.clamp(0.0, 0.1);
98 +
        self.rested += dt;
99 +
        let cursor = self.cursor;
100 +
        let still = self.still;
101 +
        for d in &mut self.dots {
102 +
            let target = match cursor {
103 +
                Some((x, y)) if (d.x - x).hypot(d.y - y) <= LIGHT_RADIUS => 1.0,
104 +
                _ => 0.0,
105 +
            };
106 +
            d.lit = if still {
107 +
                target
108 +
            } else if target > d.lit {
109 +
                (d.lit + dt / LIT_IN).min(target)
110 +
            } else {
111 +
                (d.lit - dt / LIT_OUT).max(target)
112 +
            };
113 +
        }
114 +
        let fade_to = if cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
115 +
        self.link_fade = if still {
116 +
            fade_to
117 +
        } else if fade_to > self.link_fade {
118 +
            (self.link_fade + dt / LIT_IN).min(1.0)
119 +
        } else {
120 +
            (self.link_fade - dt / LIT_OUT).max(0.0)
121 +
        };
122 +
    }
123 +
124 +
    /// Whether lighting or links are still easing.
125 +
    pub fn easing(&self) -> bool {
126 +
        self.dots.iter().any(|d| d.lit > 0.0 && d.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
127 +
    }
128 +
129 +
    fn colour(c: &Colours, tone: Tone, bright: f32, lit: f32) -> Rgba {
130 +
        let (rest, hot) = match tone {
131 +
            Tone::Mark => (c.mark, c.lit),
132 +
            Tone::Ok => (with_alpha(c.ok, 0.45), with_alpha(c.ok, 0.95)),
133 +
            Tone::Warn => (with_alpha(c.warn, 0.45), with_alpha(c.warn, 0.95)),
134 +
            Tone::Bad => (with_alpha(c.bad, 0.45), with_alpha(c.bad, 0.95)),
135 +
        };
136 +
        let rest = [rest[0], rest[1], rest[2], rest[3] * bright];
137 +
        let t = lit.clamp(0.0, 1.0);
138 +
        [
139 +
            rest[0] + (hot[0] - rest[0]) * t,
140 +
            rest[1] + (hot[1] - rest[1]) * t,
141 +
            rest[2] + (hot[2] - rest[2]) * t,
142 +
            rest[3] + (hot[3] - rest[3]) * t,
143 +
        ]
144 +
    }
145 +
146 +
    /// 8 floats a dot, as sky::Sky::instances lays them out.
147 +
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
148 +
        out.clear();
149 +
        for d in &self.dots {
150 +
            let col = Field::colour(c, d.tone, d.bright, d.lit);
151 +
            let size = d.size * (1.0 + 0.35 * d.lit);
152 +
            out.extend_from_slice(&[d.x, d.y, size, d.shape, col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
153 +
        }
154 +
    }
155 +
156 +
    /// Lit dots of a group joined in order, where near each other.
157 +
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
158 +
        out.clear();
159 +
        if self.link_fade <= 0.0 {
160 +
            return;
161 +
        }
162 +
        let mut lit: Vec<&Dot> = self.dots.iter().filter(|d| d.group != Dot::NONE && d.lit > 0.05).collect();
163 +
        lit.sort_by_key(|d| (d.group, d.order));
164 +
        for pair in lit.windows(2) {
165 +
            let (a, b) = (pair[0], pair[1]);
166 +
            if a.group != b.group || (a.x - b.x).hypot(a.y - b.y) > LINK_MAX {
167 +
                continue;
168 +
            }
169 +
            let alpha = c.link[3] * a.lit.min(b.lit) * self.link_fade;
170 +
            let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
171 +
            out.extend_from_slice(&[a.x, a.y, col[0], col[1], col[2], col[3], b.x, b.y, col[0], col[1], col[2], col[3]]);
172 +
        }
173 +
    }
174 +
175 +
    /// The name above the named thing a resting cursor is by.
176 +
    pub fn label(&self) -> Option<(&str, f32, f32)> {
177 +
        let (x, y) = self.cursor?;
178 +
        if self.rested < LABEL_REST {
179 +
            return None;
180 +
        }
181 +
        self.named
182 +
            .iter()
183 +
            .map(|n| (n, (n.x - x).hypot(n.y - y)))
184 +
            .filter(|(n, d)| *d <= LABEL_NEAR.max(n.reach))
185 +
            .min_by(|a, b| a.1.total_cmp(&b.1))
186 +
            .map(|(n, _)| (n.name.as_str(), n.x, n.y - n.reach - 10.0))
187 +
    }
188 +
}
189 +
190 +
fn with_alpha(c: Rgba, a: f32) -> Rgba {
191 +
    [c[0], c[1], c[2], a]
192 +
}
193 +
194 +
/// A number from 0 to 1 from bits of a hash (sky::hash).
195 +
pub fn unit(h: u64, shift: u32) -> f32 {
196 +
    ((h >> shift) & 0xffff) as f32 / 65536.0
197 +
}
198 +
199 +
/// The page's margins beside its content column, when both are wide
200 +
/// enough to hold something: (left width, right start).
201 +
pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> {
202 +
    let (x0, x1) = avoid?;
203 +
    (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1))
204 +
}
205 +
206 +
#[cfg(test)]
207 +
mod tests {
208 +
    use super::*;
209 +
210 +
    #[test]
211 +
    fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() {
212 +
        let mut f = Field::new(false);
213 +
        for i in 0..4 {
214 +
            let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0);
215 +
            d.group = 0;
216 +
            d.order = i;
217 +
            f.dots.push(d);
218 +
        }
219 +
        f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0));
220 +
        f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 });
221 +
        f.point(Some((130.0, 100.0)));
222 +
        for _ in 0..30 {
223 +
            f.light(1.0 / 60.0);
224 +
        }
225 +
        assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0);
226 +
        let c = Colours::default();
227 +
        let mut links = Vec::new();
228 +
        f.links(&c, &mut links);
229 +
        assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away");
230 +
        assert_eq!(f.label().map(|l| l.0), Some("earth-1"));
231 +
        let mut inst = Vec::new();
232 +
        f.instances(&c, &mut inst);
233 +
        assert_eq!(inst.len(), 5 * 8);
234 +
    }
235 +
236 +
    #[test]
237 +
    fn status_tones_come_from_the_pill_names() {
238 +
        assert_eq!(Tone::from_name("green"), Tone::Ok);
239 +
        assert_eq!(Tone::from_name("amber"), Tone::Warn);
240 +
        assert_eq!(Tone::from_name("red"), Tone::Bad);
241 +
        assert_eq!(Tone::from_name("grey"), Tone::Mark);
242 +
        assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0)));
243 +
        assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None);
244 +
    }
245 +
}
added crates/backdrop/src/mark.rs +147 −0
1 +
//! The mark (docs/backgrounds.md §2): sign in, register and device sign-in.
2 +
//! The Oxygen mark's two rings drawn in dots, turning opposite ways so the
3 +
//! stream runs through the middle like the mark's infinity, each with its
4 +
//! core. Brand only: no data.
5 +
6 +
use crate::field::{unit, Dot, Field};
7 +
use crate::scene::Scene;
8 +
use crate::sky::{hash, Colours};
9 +
use std::f32::consts::TAU;
10 +
11 +
/// Dots on the two rings.
12 +
const DOTS: usize = 560;
13 +
/// How fast the rings turn, at their rim, in CSS pixels a second.
14 +
const RIM_SPEED: f32 = 9.0;
15 +
16 +
struct Bead {
17 +
    ring: usize,
18 +
    angle: f32,
19 +
    /// Its distance off the ring's line, and its breathing's phase.
20 +
    off: f32,
21 +
    phase: f32,
22 +
}
23 +
24 +
pub struct Mark {
25 +
    field: Field,
26 +
    beads: Vec<Bead>,
27 +
    w: f32,
28 +
    h: f32,
29 +
    t: f32,
30 +
}
31 +
32 +
impl Mark {
33 +
    pub fn new(still: bool) -> Mark {
34 +
        let mut field = Field::new(still);
35 +
        let mut beads = Vec::new();
36 +
        for i in 0..DOTS {
37 +
            let h = hash(&format!("mark#{i}"));
38 +
            let ring = i % 2;
39 +
            // Off the ring's line by a bell-ish spread, most near it.
40 +
            let off = (unit(h, 16) + unit(h, 32) - 1.0) * 18.0;
41 +
            beads.push(Bead { ring, angle: unit(h, 0) * TAU, off, phase: unit(h, 48) * TAU });
42 +
            let mut d = Dot::new(0.0, 0.0, 1.5 + unit(h, 40) * 1.6, if i % 11 == 0 { 1.0 } else { 0.0 });
43 +
            d.bright = 0.55 + 0.45 * unit(h, 24);
44 +
            d.group = ring as u32;
45 +
            field.dots.push(d);
46 +
        }
47 +
        // Join each ring's beads in the order round it.
48 +
        for ring in 0..2 {
49 +
            let mut idx: Vec<usize> = (0..DOTS).filter(|&i| beads[i].ring == ring).collect();
50 +
            idx.sort_by(|&a, &b| beads[a].angle.total_cmp(&beads[b].angle));
51 +
            for (k, i) in idx.into_iter().enumerate() {
52 +
                field.dots[i].order = k as u32;
53 +
            }
54 +
        }
55 +
        // The two cores.
56 +
        for _ in 0..2 {
57 +
            field.dots.push(Dot::new(0.0, 0.0, 7.0, 0.0));
58 +
        }
59 +
        let mut m = Mark { field, beads, w: 0.0, h: 0.0, t: 0.0 };
60 +
        m.place();
61 +
        m
62 +
    }
63 +
64 +
    /// The rings' radius and centres for this view.
65 +
    fn rings(&self) -> (f32, [(f32, f32); 2]) {
66 +
        let r = (self.w.min(self.h * 1.6) * 0.17).clamp(110.0, 330.0);
67 +
        let (cx, cy) = (self.w / 2.0, self.h / 2.0);
68 +
        (r, [(cx - r, cy), (cx + r, cy)])
69 +
    }
70 +
71 +
    fn place(&mut self) {
72 +
        let (r, centres) = self.rings();
73 +
        let spin = RIM_SPEED / r * self.t;
74 +
        for (i, b) in self.beads.iter().enumerate() {
75 +
            // The left ring turns one way and the right the other.
76 +
            let a = b.angle + if b.ring == 0 { spin } else { -spin };
77 +
            let rr = r + b.off + 3.0 * (self.t * 0.4 + b.phase).sin();
78 +
            let (cx, cy) = centres[b.ring];
79 +
            self.field.dots[i].x = cx + a.cos() * rr;
80 +
            self.field.dots[i].y = cy + a.sin() * rr;
81 +
        }
82 +
        for (k, (cx, cy)) in centres.iter().enumerate() {
83 +
            let d = &mut self.field.dots[DOTS + k];
84 +
            d.x = *cx;
85 +
            d.y = *cy;
86 +
        }
87 +
    }
88 +
}
89 +
90 +
impl Scene for Mark {
91 +
    fn resize(&mut self, w: f32, h: f32, _avoid: Option<(f32, f32)>) {
92 +
        self.w = w;
93 +
        self.h = h;
94 +
        self.place();
95 +
    }
96 +
    fn point(&mut self, at: Option<(f32, f32)>) {
97 +
        self.field.point(at);
98 +
    }
99 +
    fn step(&mut self, dt: f32) {
100 +
        if !self.field.still {
101 +
            self.t += dt.clamp(0.0, 0.1);
102 +
        }
103 +
        self.place();
104 +
        self.field.light(dt);
105 +
    }
106 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
107 +
        self.field.instances(c, out)
108 +
    }
109 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
110 +
        self.field.links(c, out)
111 +
    }
112 +
    fn label(&self) -> Option<(&str, f32, f32)> {
113 +
        None
114 +
    }
115 +
    fn busy(&self) -> bool {
116 +
        !self.field.still || self.field.easing()
117 +
    }
118 +
    fn still(&self) -> bool {
119 +
        self.field.still
120 +
    }
121 +
    fn size(&self) -> (f32, f32) {
122 +
        (self.w, self.h)
123 +
    }
124 +
}
125 +
126 +
#[cfg(test)]
127 +
mod tests {
128 +
    use super::*;
129 +
130 +
    #[test]
131 +
    fn two_rings_turn_opposite_ways_about_their_cores() {
132 +
        let mut m = Mark::new(false);
133 +
        m.resize(1600.0, 900.0, None);
134 +
        let (r, centres) = m.rings();
135 +
        assert!(centres[0].0 < 800.0 && centres[1].0 > 800.0);
136 +
        for (i, b) in m.beads.iter().enumerate() {
137 +
            let (cx, cy) = centres[b.ring];
138 +
            let d = (m.field.dots[i].x - cx).hypot(m.field.dots[i].y - cy);
139 +
            assert!((d - r).abs() <= 22.0, "on its ring: {d} against {r}");
140 +
        }
141 +
        let before = (m.field.dots[0].x, m.field.dots[0].y);
142 +
        m.step(0.1);
143 +
        let moved = (m.field.dots[0].x - before.0).hypot(m.field.dots[0].y - before.1);
144 +
        assert!(moved > 0.0 && moved < 2.0, "slow: {moved} px in a tenth of a second");
145 +
        assert!(m.label().is_none(), "brand only: nothing to name");
146 +
    }
147 +
}
added crates/backdrop/src/ripples.rs +239 −0
1 +
//! Ripples (docs/backgrounds.md §2): a repository's Diffusion tab. The
2 +
//! repository in one margin, the upstreams it takes from beside it and the
3 +
//! consumers it diffuses to in the other; its changes flow out along arcs
4 +
//! to its consumers and its upstreams' flow in, each stream and node in
5 +
//! the status of its Diffusion pill, and a ripple widens from the
6 +
//! repository every few seconds. The data is the page's own.
7 +
8 +
use crate::field::{margins, unit, Dot, Field, Named, Tone};
9 +
use crate::scene::Scene;
10 +
use crate::sky::{hash, Colours};
11 +
use std::f32::consts::{PI, TAU};
12 +
13 +
/// Dots round each repository, how far out, and how fast they turn.
14 +
const RING: usize = 18;
15 +
const RING_R: f32 = 30.0;
16 +
const RING_SPEED: f32 = 6.0;
17 +
/// Dots flowing along each stream, and their speed in CSS pixels a second:
18 +
/// a stream is a flow, quicker than the drift of marks.
19 +
const FLOW: usize = 10;
20 +
const FLOW_SPEED: f32 = 40.0;
21 +
/// A ripple every this many seconds, widening this far over this long.
22 +
const RIPPLE_EVERY: f32 = 5.0;
23 +
const RIPPLE_R: f32 = 280.0;
24 +
const RIPPLE_S: f32 = 3.2;
25 +
const RIPPLE_DOTS: usize = 40;
26 +
27 +
/// One repository on the page: its name and the tone its dots take.
28 +
pub struct Node {
29 +
    pub name: String,
30 +
    pub tone: Tone,
31 +
}
32 +
33 +
pub struct Ripples {
34 +
    field: Field,
35 +
    /// The repository first, then its upstreams, then its consumers.
36 +
    nodes: Vec<Node>,
37 +
    ups: usize,
38 +
    pos: Vec<(f32, f32)>,
39 +
    /// Streams: from, to, and their dots' first index.
40 +
    streams: Vec<(usize, usize, usize)>,
41 +
    ripple_at: usize,
42 +
    w: f32,
43 +
    h: f32,
44 +
    t: f32,
45 +
}
46 +
47 +
impl Ripples {
48 +
    pub fn new(repo: String, upstreams: Vec<Node>, consumers: Vec<Node>, still: bool) -> Ripples {
49 +
        let ups = upstreams.len();
50 +
        let mut nodes = vec![Node { name: repo, tone: Tone::Mark }];
51 +
        nodes.extend(upstreams);
52 +
        nodes.extend(consumers);
53 +
        let mut field = Field::new(still);
54 +
        for (i, n) in nodes.iter().enumerate() {
55 +
            let mut core = Dot::new(0.0, 0.0, if i == 0 { 9.0 } else { 7.0 }, 4.0);
56 +
            core.tone = n.tone;
57 +
            field.dots.push(core);
58 +
            for k in 0..RING {
59 +
                let mut d = Dot::new(0.0, 0.0, 2.2, 0.0);
60 +
                d.tone = n.tone;
61 +
                d.group = i as u32;
62 +
                d.order = k as u32;
63 +
                d.bright = 0.6 + 0.4 * unit(hash(&format!("{}#{k}", n.name)), 0);
64 +
                field.dots.push(d);
65 +
            }
66 +
        }
67 +
        // Upstreams flow into the repository; the repository flows out to
68 +
        // each consumer, the stream in the consumer's tone.
69 +
        let mut streams = Vec::new();
70 +
        for (i, node) in nodes.iter().enumerate() {
71 +
            let (from, to) = match i {
72 +
                0 => continue,
73 +
                i if i <= ups => (i, 0),
74 +
                i => (0, i),
75 +
            };
76 +
            streams.push((from, to, field.dots.len()));
77 +
            for _ in 0..FLOW {
78 +
                let mut d = Dot::new(0.0, 0.0, 2.0, 0.0);
79 +
                d.tone = node.tone;
80 +
                field.dots.push(d);
81 +
            }
82 +
        }
83 +
        let ripple_at = field.dots.len();
84 +
        for _ in 0..RIPPLE_DOTS {
85 +
            let mut d = Dot::new(0.0, 0.0, 1.8, 0.0);
86 +
            d.bright = 0.0;
87 +
            field.dots.push(d);
88 +
        }
89 +
        let mut r = Ripples { field, nodes, ups, pos: Vec::new(), streams, ripple_at, w: 0.0, h: 0.0, t: 0.0 };
90 +
        r.layout(None);
91 +
        r
92 +
    }
93 +
94 +
    fn layout(&mut self, avoid: Option<(f32, f32)>) {
95 +
        let (w, h) = (self.w, self.h);
96 +
        let n = self.nodes.len();
97 +
        let cons = n - 1 - self.ups;
98 +
        let spread = |i: usize, of: usize, top: f32, bottom: f32| {
99 +
            if of <= 1 {
100 +
                (top + bottom) / 2.0
101 +
            } else {
102 +
                top + (bottom - top) * i as f32 / (of - 1) as f32
103 +
            }
104 +
        };
105 +
        let (repo_x, up_x, con_x) = match margins(w, avoid) {
106 +
            Some((l, x1)) => (l * 0.62, l * 0.22, x1 + (w - x1) * 0.45),
107 +
            None => (w * 0.12, w * 0.04, w * 0.88),
108 +
        };
109 +
        self.pos = (0..n)
110 +
            .map(|i| match i {
111 +
                0 => (repo_x, h * 0.5),
112 +
                i if i <= self.ups => (up_x, spread(i - 1, self.ups, h * 0.25, h * 0.75)),
113 +
                i => (con_x, spread(i - 1 - self.ups, cons, h * 0.15, h * 0.85)),
114 +
            })
115 +
            .collect();
116 +
        self.field.named =
117 +
            self.nodes.iter().zip(&self.pos).map(|(nd, &(x, y))| Named { name: nd.name.clone(), x, y, reach: RING_R + 6.0 }).collect();
118 +
    }
119 +
120 +
    fn place(&mut self) {
121 +
        let mut at = 0;
122 +
        let spin = RING_SPEED / RING_R * self.t;
123 +
        for (i, &(x, y)) in self.pos.iter().enumerate() {
124 +
            self.field.dots[at].x = x;
125 +
            self.field.dots[at].y = y;
126 +
            at += 1;
127 +
            for k in 0..RING {
128 +
                let a = k as f32 / RING as f32 * TAU + if i % 2 == 0 { spin } else { -spin };
129 +
                self.field.dots[at].x = x + a.cos() * RING_R;
130 +
                self.field.dots[at].y = y + a.sin() * RING_R;
131 +
                at += 1;
132 +
            }
133 +
        }
134 +
        // Streams: a gentle arc from each source to its destination, its
135 +
        // dots evenly spaced and moving along it, faded at the ends.
136 +
        for &(from, to, first) in &self.streams {
137 +
            let (a, b) = (self.pos[from], self.pos[to]);
138 +
            let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0);
139 +
            let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 - len * 0.18);
140 +
            for k in 0..FLOW {
141 +
                let s = (k as f32 / FLOW as f32 + self.t * FLOW_SPEED / len).fract();
142 +
                let u = 1.0 - s;
143 +
                let d = &mut self.field.dots[first + k];
144 +
                d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0;
145 +
                d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1;
146 +
                d.bright = (s * PI).sin();
147 +
            }
148 +
        }
149 +
        // The ripple: a ring of dots widening from the repository.
150 +
        let p = (self.t % RIPPLE_EVERY) / RIPPLE_S;
151 +
        let (x, y) = self.pos[0];
152 +
        for k in 0..RIPPLE_DOTS {
153 +
            let d = &mut self.field.dots[self.ripple_at + k];
154 +
            let a = k as f32 / RIPPLE_DOTS as f32 * TAU;
155 +
            let r = RING_R + RIPPLE_R * p.min(1.0);
156 +
            d.x = x + a.cos() * r;
157 +
            d.y = y + a.sin() * r;
158 +
            d.bright = if self.field.still || p >= 1.0 { 0.0 } else { 0.8 * (1.0 - p) };
159 +
        }
160 +
    }
161 +
}
162 +
163 +
impl Scene for Ripples {
164 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
165 +
        self.w = w;
166 +
        self.h = h;
167 +
        self.layout(avoid);
168 +
        self.place();
169 +
    }
170 +
    fn point(&mut self, at: Option<(f32, f32)>) {
171 +
        self.field.point(at);
172 +
    }
173 +
    fn step(&mut self, dt: f32) {
174 +
        if !self.field.still {
175 +
            self.t += dt.clamp(0.0, 0.1);
176 +
        }
177 +
        self.place();
178 +
        self.field.light(dt);
179 +
    }
180 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
181 +
        self.field.instances(c, out)
182 +
    }
183 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
184 +
        self.field.links(c, out)
185 +
    }
186 +
    fn label(&self) -> Option<(&str, f32, f32)> {
187 +
        self.field.label()
188 +
    }
189 +
    fn busy(&self) -> bool {
190 +
        !self.field.still || self.field.easing()
191 +
    }
192 +
    fn still(&self) -> bool {
193 +
        self.field.still
194 +
    }
195 +
    fn size(&self) -> (f32, f32) {
196 +
        (self.w, self.h)
197 +
    }
198 +
}
199 +
200 +
#[cfg(test)]
201 +
mod tests {
202 +
    use super::*;
203 +
204 +
    fn node(name: &str, tone: Tone) -> Node {
205 +
        Node { name: name.into(), tone }
206 +
    }
207 +
208 +
    #[test]
209 +
    fn upstreams_flow_in_and_consumers_out_in_their_pills_tones() {
210 +
        let mut r = Ripples::new(
211 +
            "andy/api".into(),
212 +
            vec![node("andy/base", Tone::Ok)],
213 +
            vec![node("andy/game", Tone::Ok), node("andy/other", Tone::Bad)],
214 +
            false,
215 +
        );
216 +
        r.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
217 +
        assert!(r.pos[0].0 < 707.0 && r.pos[1].0 < r.pos[0].0, "the repository and its upstream in the left margin");
218 +
        assert!(r.pos[2].0 > 1838.0 && r.pos[3].0 > 1838.0, "its consumers in the right");
219 +
        assert_eq!(r.streams, vec![(1, 0, 4 * (RING + 1)), (0, 2, 4 * (RING + 1) + FLOW), (0, 3, 4 * (RING + 1) + 2 * FLOW)]);
220 +
        let bad_stream = r.streams[2].2;
221 +
        assert!(r.field.dots[bad_stream..bad_stream + FLOW].iter().all(|d| d.tone == Tone::Bad), "a failing consumer's stream is red");
222 +
        let before = r.field.dots[bad_stream].x;
223 +
        r.step(0.1);
224 +
        assert_ne!(before, r.field.dots[bad_stream].x, "streams flow");
225 +
        r.point(Some(r.pos[3]));
226 +
        for _ in 0..30 {
227 +
            r.step(0.02);
228 +
        }
229 +
        assert_eq!(r.label().map(|l| l.0), Some("andy/other"));
230 +
    }
231 +
232 +
    #[test]
233 +
    fn still_keeps_the_ripple_away() {
234 +
        let mut r = Ripples::new("andy/api".into(), vec![], vec![node("andy/game", Tone::Mark)], true);
235 +
        r.resize(1600.0, 900.0, None);
236 +
        r.step(0.1);
237 +
        assert!(r.field.dots[r.ripple_at..].iter().all(|d| d.bright == 0.0));
238 +
    }
239 +
}
added crates/backdrop/src/scene.rs +54 −0
1 +
//! A backdrop's scene (docs/backgrounds.md §2): what it draws and how it
2 +
//! moves. The engine (lib.rs) drives any of them the same way.
3 +
4 +
use crate::sky::Colours;
5 +
6 +
pub trait Scene {
7 +
    /// The view is now `w` by `h` CSS pixels, the page's content column
8 +
    /// between `avoid`'s left and right.
9 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>);
10 +
    /// The cursor moved to `(x, y)`, or left the page.
11 +
    fn point(&mut self, at: Option<(f32, f32)>);
12 +
    /// Move on by `dt` seconds.
13 +
    fn step(&mut self, dt: f32);
14 +
    /// The marks, 8 floats each (sky::Sky::instances).
15 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>);
16 +
    /// The links, 12 floats each (sky::Sky::links).
17 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>);
18 +
    /// The name a resting cursor shows, and where.
19 +
    fn label(&self) -> Option<(&str, f32, f32)>;
20 +
    /// Whether a frame would show anything new.
21 +
    fn busy(&self) -> bool;
22 +
    fn still(&self) -> bool;
23 +
    fn size(&self) -> (f32, f32);
24 +
}
25 +
26 +
impl Scene for crate::sky::Sky {
27 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
28 +
        crate::sky::Sky::resize(self, w, h, avoid)
29 +
    }
30 +
    fn point(&mut self, at: Option<(f32, f32)>) {
31 +
        crate::sky::Sky::point(self, at)
32 +
    }
33 +
    fn step(&mut self, dt: f32) {
34 +
        crate::sky::Sky::step(self, dt)
35 +
    }
36 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
37 +
        crate::sky::Sky::instances(self, c, out)
38 +
    }
39 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
40 +
        crate::sky::Sky::links(self, c, out)
41 +
    }
42 +
    fn label(&self) -> Option<(&str, f32, f32)> {
43 +
        crate::sky::Sky::label(self)
44 +
    }
45 +
    fn busy(&self) -> bool {
46 +
        crate::sky::Sky::busy(self)
47 +
    }
48 +
    fn still(&self) -> bool {
49 +
        self.still
50 +
    }
51 +
    fn size(&self) -> (f32, f32) {
52 +
        (self.w, self.h)
53 +
    }
54 +
}
added crates/backdrop/src/swarm.rs +209 −0
1 +
//! The swarm (docs/backgrounds.md §2): Admin › Runners › Images. The hub
2 +
//! and the runners as nodes, each ringed by a dot for every image it
3 +
//! holds; while a runner fetches an image, chunks (small squares) stream
4 +
//! to it from every holder, the hub and its peers alike, as Jetstream's
5 +
//! swarm fetches them. The data is the page's own snapshot.
6 +
7 +
use crate::field::{margins, unit, Dot, Field, Named};
8 +
use crate::scene::Scene;
9 +
use crate::sky::{hash, Colours, MAX_SPEED};
10 +
use std::f32::consts::{PI, TAU};
11 +
12 +
/// Chunks streaming along each holder-to-fetcher path, their speed, and
13 +
/// the most streams drawn.
14 +
const CHUNKS: usize = 8;
15 +
const CHUNK_SPEED: f32 = 40.0;
16 +
const MAX_STREAMS: usize = 40;
17 +
18 +
/// One image: the nodes that hold it and those fetching it now.
19 +
pub struct Image {
20 +
    pub held: Vec<usize>,
21 +
    pub fetching: Vec<usize>,
22 +
}
23 +
24 +
struct Held {
25 +
    node: usize,
26 +
    radius: f32,
27 +
    angle: f32,
28 +
    speed: f32,
29 +
}
30 +
31 +
pub struct Swarm {
32 +
    field: Field,
33 +
    /// The hub first, then the runners.
34 +
    names: Vec<String>,
35 +
    pos: Vec<(f32, f32)>,
36 +
    held: Vec<Held>,
37 +
    /// Streams: from, to, and their chunks' first index.
38 +
    streams: Vec<(usize, usize, usize)>,
39 +
    w: f32,
40 +
    h: f32,
41 +
    t: f32,
42 +
}
43 +
44 +
impl Swarm {
45 +
    pub fn new(names: Vec<String>, images: Vec<Image>, still: bool) -> Swarm {
46 +
        let mut field = Field::new(still);
47 +
        for i in 0..names.len() {
48 +
            field.dots.push(Dot::new(0.0, 0.0, if i == 0 { 10.0 } else { 8.0 }, 4.0));
49 +
        }
50 +
        let mut held = Vec::new();
51 +
        let mut count = vec![0u32; names.len()];
52 +
        for (n, img) in images.iter().enumerate() {
53 +
            for &node in img.held.iter().filter(|&&n| n < names.len()) {
54 +
                let k = count[node];
55 +
                count[node] += 1;
56 +
                let h = hash(&format!("{}#{n}", names[node]));
57 +
                let radius = (16.0 + (k % 5) as f32 * 7.0 + unit(h, 0) * 4.0) * 1.4;
58 +
                held.push(Held {
59 +
                    node,
60 +
                    radius,
61 +
                    angle: unit(h, 16) * TAU,
62 +
                    speed: (MAX_SPEED / radius).min(0.2) * (0.5 + 0.5 * unit(h, 32)),
63 +
                });
64 +
                let mut d = Dot::new(0.0, 0.0, 2.6, 0.0);
65 +
                d.group = node as u32;
66 +
                d.order = k;
67 +
                d.bright = 0.7;
68 +
                field.dots.push(d);
69 +
            }
70 +
        }
71 +
        let mut streams = Vec::new();
72 +
        'images: for img in &images {
73 +
            for &to in img.fetching.iter().filter(|&&n| n < names.len()) {
74 +
                for &from in img.held.iter().filter(|&&n| n < names.len() && n != to) {
75 +
                    if streams.len() == MAX_STREAMS {
76 +
                        break 'images;
77 +
                    }
78 +
                    streams.push((from, to, field.dots.len()));
79 +
                    for _ in 0..CHUNKS {
80 +
                        let mut d = Dot::new(0.0, 0.0, 2.2, 5.0);
81 +
                        d.bright = 0.0;
82 +
                        field.dots.push(d);
83 +
                    }
84 +
                }
85 +
            }
86 +
        }
87 +
        let mut s = Swarm { field, names, pos: Vec::new(), held, streams, w: 0.0, h: 0.0, t: 0.0 };
88 +
        s.layout(None);
89 +
        s
90 +
    }
91 +
92 +
    fn layout(&mut self, avoid: Option<(f32, f32)>) {
93 +
        let (w, h) = (self.w, self.h);
94 +
        let runners = self.names.len().saturating_sub(1);
95 +
        let (hub_x, run_x) = match margins(w, avoid) {
96 +
            Some((l, x1)) => (l * 0.5, x1 + (w - x1) * 0.5),
97 +
            None => (w * 0.1, w * 0.9),
98 +
        };
99 +
        self.pos = (0..self.names.len())
100 +
            .map(|i| match i {
101 +
                0 => (hub_x, h * 0.5),
102 +
                _ if runners <= 1 => (run_x, h * 0.5),
103 +
                i => (run_x, h * 0.2 + h * 0.6 * (i - 1) as f32 / (runners - 1) as f32),
104 +
            })
105 +
            .collect();
106 +
        self.field.named = self
107 +
            .names
108 +
            .iter()
109 +
            .zip(&self.pos)
110 +
            .enumerate()
111 +
            .map(|(i, (name, &(x, y)))| {
112 +
                let reach = self.held.iter().filter(|h| h.node == i).map(|h| h.radius).fold(12.0, f32::max);
113 +
                Named { name: name.clone(), x, y, reach }
114 +
            })
115 +
            .collect();
116 +
    }
117 +
118 +
    fn place(&mut self) {
119 +
        let n = self.names.len();
120 +
        for (i, &(x, y)) in self.pos.iter().enumerate() {
121 +
            self.field.dots[i].x = x;
122 +
            self.field.dots[i].y = y;
123 +
        }
124 +
        for (k, hd) in self.held.iter().enumerate() {
125 +
            let (x, y) = self.pos[hd.node];
126 +
            let a = hd.angle + hd.speed * self.t;
127 +
            self.field.dots[n + k].x = x + a.cos() * hd.radius;
128 +
            self.field.dots[n + k].y = y + a.sin() * hd.radius;
129 +
        }
130 +
        for &(from, to, first) in &self.streams {
131 +
            let (a, b) = (self.pos[from], self.pos[to]);
132 +
            let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0);
133 +
            // Peers' streams bow one way, the hub's the other, so they
134 +
            // read as separate sources.
135 +
            let bow = if from == 0 { -0.12 } else { 0.12 };
136 +
            let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 + len * bow);
137 +
            for k in 0..CHUNKS {
138 +
                let s = (k as f32 / CHUNKS as f32 + self.t * CHUNK_SPEED / len).fract();
139 +
                let u = 1.0 - s;
140 +
                let d = &mut self.field.dots[first + k];
141 +
                d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0;
142 +
                d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1;
143 +
                d.bright = (s * PI).sin();
144 +
            }
145 +
        }
146 +
    }
147 +
}
148 +
149 +
impl Scene for Swarm {
150 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
151 +
        self.w = w;
152 +
        self.h = h;
153 +
        self.layout(avoid);
154 +
        self.place();
155 +
    }
156 +
    fn point(&mut self, at: Option<(f32, f32)>) {
157 +
        self.field.point(at);
158 +
    }
159 +
    fn step(&mut self, dt: f32) {
160 +
        if !self.field.still {
161 +
            self.t += dt.clamp(0.0, 0.1);
162 +
        }
163 +
        self.place();
164 +
        self.field.light(dt);
165 +
    }
166 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
167 +
        self.field.instances(c, out)
168 +
    }
169 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
170 +
        self.field.links(c, out)
171 +
    }
172 +
    fn label(&self) -> Option<(&str, f32, f32)> {
173 +
        self.field.label()
174 +
    }
175 +
    fn busy(&self) -> bool {
176 +
        !self.field.still || self.field.easing()
177 +
    }
178 +
    fn still(&self) -> bool {
179 +
        self.field.still
180 +
    }
181 +
    fn size(&self) -> (f32, f32) {
182 +
        (self.w, self.h)
183 +
    }
184 +
}
185 +
186 +
#[cfg(test)]
187 +
mod tests {
188 +
    use super::*;
189 +
190 +
    #[test]
191 +
    fn chunks_stream_to_a_fetching_runner_from_every_holder() {
192 +
        let names = vec!["hub".to_string(), "earth-1".to_string(), "plex-1".to_string()];
193 +
        let images = vec![Image { held: vec![0, 1], fetching: vec![2] }, Image { held: vec![0, 1, 2], fetching: vec![] }];
194 +
        let mut s = Swarm::new(names, images, false);
195 +
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
196 +
        assert!(s.pos[0].0 < 707.0 && s.pos[1].0 > 1838.0 && s.pos[2].0 > 1838.0, "the hub on one side, runners on the other");
197 +
        assert_eq!(s.held.len(), 5, "a dot for every image each node holds");
198 +
        assert_eq!(s.streams.iter().map(|&(a, b, _)| (a, b)).collect::<Vec<_>>(), vec![(0, 2), (1, 2)], "from the hub and the peer");
199 +
        let first = s.streams[0].2;
200 +
        let before = s.field.dots[first].x;
201 +
        s.step(0.1);
202 +
        assert_ne!(before, s.field.dots[first].x);
203 +
        s.point(Some(s.pos[1]));
204 +
        for _ in 0..30 {
205 +
            s.step(0.02);
206 +
        }
207 +
        assert_eq!(s.label().map(|l| l.0), Some("earth-1"));
208 +
    }
209 +
}
modified CHANGELOG.md +1 −0
…
6 unchanged lines
7 7
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
8 8
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
9 9
- **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar.
10 +
- **Every backdrop scene (#167, docs/backgrounds.md):** the Oxygen mark's two rings turning behind sign in, register and device sign-in; ripples on a repository's Diffusion tab, its upstreams flowing in and changes flowing out to its consumers in their pills' tones; the swarm on Admin › Runners › Images, chunks streaming to a fetching runner from every holder; and one mark adrift on a page not found or a server error.
10 11
- **The hub's sky (#165):** Explore and the signed-out home draw the same sky from the public repositories listed (a filter narrows it), never a private one, whoever looks.
11 12
- **Your sky (#157, docs/backgrounds.md):** a quiet, moving backdrop behind the dashboard, drawn in Rust compiled to WebAssembly: your repositories as clusters and their last 30 days (every change pushed, proposal and issue events, releases, Flow runs) as marks around them; the cursor lights and links them, and names the cluster it rests by. Settings › Appearance: Moving, Still or Off; still under reduced motion.
12 13
- **Signed-in pages are private (#158):** HTML for a signed-in viewer says `Cache-Control: private, no-store`, so no proxy or CDN serves one viewer's page to another.
…
167 unchanged lines
modified assets/backdrop/backdrop.js +2 −1
1 1
/**
2 -
 * Start the dashboard's sky on `canvas` with the page's `data`. `still`
2 +
 * Start a backdrop on `canvas` with the page's `data`: the scene it names
3 +
 * (docs/backgrounds.md §2), the dashboard's sky by default. `still`
3 4
 * is the viewer's choice of a still backdrop; reduced motion asks for
4 5
 * one too. Without WebGL2 there is no backdrop, and nothing breaks.
5 6
 * @param {HTMLCanvasElement} canvas
…
641 unchanged lines
replaced assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

modified crates/backdrop/src/lib.rs +73 −18
…
3 unchanged lines
4 4
//! `gl` draws it; this module is the browser around them: the frame loop,
5 5
//! the cursor, the window's size, the page's colours and when to stop.
6 6
7 +
pub mod adrift;
8 +
pub mod field;
7 9
pub mod gl;
10 +
pub mod mark;
11 +
pub mod ripples;
12 +
pub mod scene;
8 13
pub mod sky;
14 +
pub mod swarm;
9 15
16 +
use scene::Scene;
10 17
use sky::{Colours, Event, Kind, Repo, Sky};
11 18
use std::cell::RefCell;
12 19
use std::rc::Rc;
…
13 unchanged lines
26 33
    window: Window,
27 34
    canvas: HtmlCanvasElement,
28 35
    renderer: gl::Renderer,
29 -
    sky: Sky,
36 +
    scene: Box<dyn Scene>,
30 37
    colours: Colours,
31 38
    colours_at: f64,
32 39
    probe: HtmlElement,
…
32 unchanged lines
65 72
            .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten())
66 73
            .map(|el| el.get_bounding_client_rect())
67 74
            .map(|r| (r.left() as f32, r.right() as f32));
68 -
        self.sky.resize(w as f32, h as f32, avoid);
75 +
        self.scene.resize(w as f32, h as f32, avoid);
69 76
    }
70 77
71 78
    /// The page's backdrop tokens, as the browser computes them, so a
…
13 unchanged lines
85 92
            mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]),
86 93
            lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]),
87 94
            link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]),
95 +
            ok: read("--ok", [0.1, 0.5, 0.22, 1.0]),
96 +
            warn: read("--warn", [0.6, 0.4, 0.0, 1.0]),
97 +
            bad: read("--bad", [0.82, 0.14, 0.18, 1.0]),
88 98
        };
89 99
    }
90 100
…
5 unchanged lines
96 106
        }
97 107
        let idle = (t - self.input_at) / 1000.0;
98 108
        self.frame += 1;
99 -
        if !self.sky.still && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 {
109 +
        if !self.scene.still() && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 {
100 110
            return true;
101 111
        }
102 112
        let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0);
…
2 unchanged lines
105 115
            self.read_colours();
106 116
            self.colours_at = t;
107 117
        }
108 -
        self.sky.step(dt);
109 -
        self.sky.instances(&self.colours, &mut self.inst);
110 -
        self.sky.links(&self.colours, &mut self.lines);
111 -
        self.renderer.draw(self.sky.w, self.sky.h, self.pw, self.ph, &self.inst, &self.lines);
118 +
        self.scene.step(dt);
119 +
        self.scene.instances(&self.colours, &mut self.inst);
120 +
        self.scene.links(&self.colours, &mut self.lines);
121 +
        let (w, h) = self.scene.size();
122 +
        self.renderer.draw(w, h, self.pw, self.ph, &self.inst, &self.lines);
112 123
        self.show_label();
113 -
        if self.sky.still {
114 -
            self.sky.busy()
124 +
        if self.scene.still() {
125 +
            self.scene.busy()
115 126
        } else {
116 -
            idle < STOP_AFTER_S || self.sky.busy()
127 +
            idle < STOP_AFTER_S || self.scene.busy()
117 128
        }
118 129
    }
119 130
120 131
    /// The name above the cluster a cursor rests by (§4): faded in and out
121 132
    /// by CSS, which centres it on `left` and sets its foot on `top`.
122 133
    fn show_label(&mut self) {
123 -
        let next = self.sky.label().map(|(name, x, y)| (name.to_string(), x, y));
134 +
        let next = self.scene.label().map(|(name, x, y)| (name.to_string(), x, y));
124 135
        let style = self.label.style();
125 136
        match next {
126 137
            Some((name, x, y)) => {
…
47 unchanged lines
174 185
    (repos, events)
175 186
}
176 187
177 -
/// Start the dashboard's sky on `canvas` with the page's `data`. `still`
188 +
fn strings(v: &JsValue) -> Vec<String> {
189 +
    js_sys::Array::from(v).iter().filter_map(|x| x.as_string()).collect()
190 +
}
191 +
192 +
/// A Diffusion node, `[name, tone]`.
193 +
fn ripple_nodes(v: &JsValue) -> Vec<ripples::Node> {
194 +
    js_sys::Array::from(v)
195 +
        .iter()
196 +
        .filter_map(|n| {
197 +
            let n = js_sys::Array::from(&n);
198 +
            Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) })
199 +
        })
200 +
        .collect()
201 +
}
202 +
203 +
/// The scene the page's data names (`scene`), the sky when it names none.
204 +
fn read_scene(data: &JsValue, still: bool) -> Box<dyn Scene> {
205 +
    match get(data, "scene").as_string().as_deref() {
206 +
        Some("mark") => Box::new(mark::Mark::new(still)),
207 +
        Some("adrift") => Box::new(adrift::Adrift::new(still)),
208 +
        Some("ripples") => Box::new(ripples::Ripples::new(
209 +
            get(data, "repo").as_string().unwrap_or_default(),
210 +
            ripple_nodes(&get(data, "upstreams")),
211 +
            ripple_nodes(&get(data, "consumers")),
212 +
            still,
213 +
        )),
214 +
        Some("swarm") => {
215 +
            let images = js_sys::Array::from(&get(data, "images"))
216 +
                .iter()
217 +
                .map(|img| {
218 +
                    let idx =
219 +
                        |key: &str| js_sys::Array::from(&get(&img, key)).iter().filter_map(|v| v.as_f64()).map(|v| v as usize).collect();
220 +
                    swarm::Image { held: idx("held"), fetching: idx("fetching") }
221 +
                })
222 +
                .collect();
223 +
            Box::new(swarm::Swarm::new(strings(&get(data, "nodes")), images, still))
224 +
        }
225 +
        _ => {
226 +
            let (repos, events) = read_data(data);
227 +
            Box::new(Sky::new(&repos, &events, 0.0, 0.0, still))
228 +
        }
229 +
    }
230 +
}
231 +
232 +
/// Start a backdrop on `canvas` with the page's `data`: the scene it names
233 +
/// (docs/backgrounds.md §2), the dashboard's sky by default. `still`
178 234
/// is the viewer's choice of a still backdrop; reduced motion asks for
179 235
/// one too. Without WebGL2 there is no backdrop, and nothing breaks.
180 236
#[wasm_bindgen]
…
21 unchanged lines
202 258
    label.set_attribute("aria-hidden", "true")?;
203 259
    body.append_child(&label)?;
204 260
205 -
    let (repos, events) = read_data(&data);
206 -
    let sky = Sky::new(&repos, &events, 0.0, 0.0, still);
207 -
    let colours = Colours { mark: [0.0; 4], lit: [0.0; 4], link: [0.0; 4] };
261 +
    let scene = read_scene(&data, still);
262 +
    let colours = Colours::default();
208 263
    let engine = Rc::new(RefCell::new(Engine {
209 264
        window: window.clone(),
210 265
        canvas,
211 266
        renderer,
212 -
        sky,
267 +
        scene,
213 268
        colours,
214 269
        colours_at: f64::NEG_INFINITY,
215 270
        probe,
…
37 unchanged lines
253 308
        let moved = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
254 309
            {
255 310
                let mut e = engine.borrow_mut();
256 -
                e.sky.point(Some((ev.client_x() as f32, ev.client_y() as f32)));
311 +
                e.scene.point(Some((ev.client_x() as f32, ev.client_y() as f32)));
257 312
                e.input_at = e.now();
258 313
            }
259 314
            wake(&engine, &frame);
…
5 unchanged lines
265 320
        let (engine, frame) = (engine.clone(), frame.clone());
266 321
        let left = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
267 322
            if ev.related_target().is_none() {
268 -
                engine.borrow_mut().sky.point(None);
323 +
                engine.borrow_mut().scene.point(None);
269 324
                wake(&engine, &frame);
270 325
            }
271 326
        });
…
28 unchanged lines
modified crates/backdrop/src/sky.rs +8 −4
…
139 unchanged lines
140 140
/// Straight RGBA, 0 to 1, as the page's tokens give them.
141 141
pub type Rgba = [f32; 4];
142 142
143 -
/// The three backdrop tokens (§3).
144 -
#[derive(Clone, Copy, Debug, PartialEq)]
143 +
/// The three backdrop tokens (§3), and the page's status colours for the
144 +
/// scenes whose data has a status (Diffusion's pills).
145 +
#[derive(Clone, Copy, Debug, Default, PartialEq)]
145 146
pub struct Colours {
146 147
    pub mark: Rgba,
147 148
    pub lit: Rgba,
148 149
    pub link: Rgba,
150 +
    pub ok: Rgba,
151 +
    pub warn: Rgba,
152 +
    pub bad: Rgba,
149 153
}
150 154
151 155
/// The scene.
…
370 unchanged lines
522 526
        assert!(s.marks[2].lit > 0.99, "lit near the cursor");
523 527
        assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled");
524 528
        let mut links = Vec::new();
525 -
        let colours = Colours { mark: [0.5, 0.5, 0.5, 0.2], lit: [0.0, 0.4, 0.9, 0.7], link: [0.0, 0.4, 0.9, 0.3] };
529 +
        let colours = Colours { mark: [0.5, 0.5, 0.5, 0.2], lit: [0.0, 0.4, 0.9, 0.7], link: [0.0, 0.4, 0.9, 0.3], ..Default::default() };
526 530
        s.links(&colours, &mut links);
527 531
        assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined");
528 532
        // Resting fades the links; leaving fades the light.
…
24 unchanged lines
553 557
    #[test]
554 558
    fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() {
555 559
        let mut s = sky(false);
556 -
        let colours = Colours { mark: [1.0, 1.0, 1.0, 0.5], lit: [0.0, 0.0, 1.0, 1.0], link: [0.0, 0.0, 1.0, 0.3] };
560 +
        let colours = Colours { mark: [1.0, 1.0, 1.0, 0.5], lit: [0.0, 0.0, 1.0, 1.0], link: [0.0, 0.0, 1.0, 0.3], ..Default::default() };
557 561
        let mut inst = Vec::new();
558 562
        s.instances(&colours, &mut inst);
559 563
        assert_eq!(inst.len(), s.marks.len() * 8);
…
53 unchanged lines
modified crates/hub/src/web/diffusion.rs +19 −1
…
19 unchanged lines
20 20
    follows: Vec<(RepoRow, Consumer)>,
21 21
    /// This repository has no checks of its own: its bumps are amber.
22 22
    unverified: bool,
23 +
    /// The ripples backdrop's data (docs/backgrounds.md §2): this page's
24 +
    /// own repositories and pill tones, JSON safe in a script.
25 +
    backdrop: String,
23 26
}
24 27
28 +
/// The ripples' data: the repository, the upstreams it takes from (each in
29 +
/// its upstream half's tone) and the consumers it diffuses to (each in its
30 +
/// own half's tone), as `{scene, repo, upstreams: [[name, tone]],
31 +
/// consumers: [[name, tone]]}`.
32 +
fn ripples_json(slug: &str, used_by: &[Consumer], follows: &[(RepoRow, Consumer)]) -> String {
33 +
    let tone = |class: &str| class.trim_start_matches("df-").to_string();
34 +
    let upstreams: Vec<(String, String)> =
35 +
        follows.iter().map(|(up, c)| (format!("{}/{}", up.owner, up.name), tone(c.upstream.class))).collect();
36 +
    let consumers: Vec<(String, String)> = used_by.iter().map(|c| (format!("{}/{}", c.owner, c.name), tone(c.downstream.class))).collect();
37 +
    serde_json::json!({ "scene": "ripples", "repo": slug, "upstreams": upstreams, "consumers": consumers })
38 +
        .to_string()
39 +
        .replace('<', "\\u003c")
40 +
}
41 +
25 42
/// `GET /{owner}/{name}/diffusion`.
26 43
pub async fn page(
27 44
    State(app): State<Shared>,
…
5 unchanged lines
33 50
    let used_by = crate::diffusion::consumers(&app, &row, viewer.account()).await.unwrap_or_default();
34 51
    let follows = crate::diffusion::follows(&app, &row, viewer.account()).await;
35 52
    let unverified = crate::diffusion::unverified(&app, &row).await;
36 -
    Ok(Page(DiffusionPage { shell, repo: RepoShell::new(&app, &row, "diffusion").with_role(role), used_by, follows, unverified }))
53 +
    let backdrop = ripples_json(&format!("{owner}/{name}"), &used_by, &follows);
54 +
    Ok(Page(DiffusionPage { shell, repo: RepoShell::new(&app, &row, "diffusion").with_role(role), used_by, follows, unverified, backdrop }))
37 55
}
38 56
39 57
#[derive(Template)]
…
15 unchanged lines
modified crates/hub/src/web/runners.rs +34 −1
…
451 unchanged lines
452 452
    on_hub: usize,
453 453
    /// Runners in the image swarm, as "name (site)".
454 454
    swarm: Vec<String>,
455 +
    /// The swarm backdrop's data (docs/backgrounds.md §2): the hub and the
456 +
    /// runners, and for each image who holds it and who fetches it now.
457 +
    backdrop: String,
455 458
}
456 459
460 +
/// The swarm's data, `{scene, nodes: [name], images: [{held, fetching}]}`,
461 +
/// the hub first, from the page's own rows.
462 +
fn swarm_json(images: &[ImageRow]) -> String {
463 +
    let mut nodes: Vec<String> = vec!["hub".into()];
464 +
    let mut list = Vec::new();
465 +
    for img in images {
466 +
        let (mut held, mut fetching) = (Vec::new(), Vec::new());
467 +
        for p in &img.places {
468 +
            let i = match p.kind {
469 +
                "hub" => 0,
470 +
                _ => match nodes.iter().position(|n| *n == p.name) {
471 +
                    Some(i) => i,
472 +
                    None => {
473 +
                        nodes.push(p.name.clone());
474 +
                        nodes.len() - 1
475 +
                    }
476 +
                },
477 +
            };
478 +
            if p.kind == "fetching" {
479 +
                fetching.push(i);
480 +
            } else {
481 +
                held.push(i);
482 +
            }
483 +
        }
484 +
        list.push(serde_json::json!({ "held": held, "fetching": fetching }));
485 +
    }
486 +
    serde_json::json!({ "scene": "swarm", "nodes": nodes, "images": list }).to_string().replace('<', "\\u003c")
487 +
}
488 +
457 489
/// `GET /admin/runners/images` (C0, docs/cylinder.md): every runner image,
458 490
/// where it is and what uses it.
459 491
pub async fn images(State(app): State<Shared>, viewer: Viewer) -> Result<Page<ImagesPage>, Error> {
…
65 unchanged lines
525 557
    let mut images: Vec<ImageRow> = rows.into_values().collect();
526 558
    images.sort_by(|a, b| b.last_used.cmp(&a.last_used).then_with(|| a.hash.cmp(&b.hash)));
527 559
    let on_hub = kept.len();
528 -
    Ok(Page(ImagesPage { shell, images, on_hub, swarm }))
560 +
    let backdrop = swarm_json(&images);
561 +
    Ok(Page(ImagesPage { shell, images, on_hub, swarm, backdrop }))
529 562
}
modified crates/hub/tests/flow.rs +7 −0
…
3104 unchanged lines
3105 3105
    let on_hub = row(&kept);
3106 3106
    assert!(on_hub.contains("img-hub") && on_hub.contains("1.0 MiB") && on_hub.contains("x86_64"), "{on_hub}");
3107 3107
    assert!(page.contains("earth-1 (home)"), "the swarm is named");
3108 +
    // The swarm backdrop (docs/backgrounds.md §2): the hub and the runners.
3109 +
    assert!(page.contains(r#"{"images":"#) && page.contains(r#""scene":"swarm""#) && page.contains(r#""earth-1""#), "the swarm's data");
3108 3110
    assert_eq!(call(&h.app, "GET", "/admin/runners/images", None, None).await.0, StatusCode::NOT_FOUND, "administrators only");
3109 3111
}
3110 3112
…
276 unchanged lines
3387 3389
    assert_eq!(st, StatusCode::OK);
3388 3390
    assert!(page.contains("Diffuses to") && page.contains("andy/game") && page.contains(&format!("#{}", p2.number)), "the bump is listed");
3389 3391
    assert!(page.contains("df-green"), "a passing run of a checked upstream is green");
3392 +
    // The ripples backdrop: the page's own repositories and tones.
3393 +
    assert!(
3394 +
        page.contains(r#""scene":"ripples""#) && page.contains(r#"["andy/game","#) && page.contains(r#""repo":"andy/api""#),
3395 +
        "the ripples' data"
3396 +
    );
3390 3397
    let (_, page) = call(&h.app, "GET", "/andy/game/diffusion", None, None).await;
3391 3398
    assert!(page.contains("Takes from") && page.contains("andy/api"));
3392 3399
    let (_, overview) = call(&h.app, "GET", "/andy/game", None, None).await;
…
164 unchanged lines
modified crates/hub/tests/issues.rs +8 −0
…
388 unchanged lines
389 389
        .await
390 390
        .unwrap();
391 391
    assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store");
392 +
    // The mark on sign in; adrift on a page not found, not on a refusal.
393 +
    assert!(get(app, "/login", None)
394 +
        .await
395 +
        .body
396 +
        .contains(r#"<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>"#));
397 +
    let lost = get(app, "/no/such/page/here", None).await;
398 +
    assert_eq!(lost.status, StatusCode::NOT_FOUND);
399 +
    assert!(lost.body.contains(r#"{"scene":"adrift"}"#));
392 400
    // Signed out, `/` has the hub's sky (#165): public repositories only.
393 401
    let sky_of = |body: &str| {
394 402
        let start = body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
…
583 unchanged lines
modified docs/backgrounds.md +39 −4
1 1
# Backdrops: living backgrounds for OxygenHub
2 2
3 -
Status: the engine, the dashboard's Your sky (oxygenhub#157) and the hub's
4 -
sky on Explore and the signed-out home (oxygenhub#165) are built
5 -
(2026-10-13); the other scenes are proposed (oxygenhub#156).
3 +
Status: every scene in §2 is built (2026-10-13): Your sky (oxygenhub#157),
4 +
the hub's sky (#165), and the mark, ripples, the swarm and adrift (#167).
6 5
7 6
## 1. What a backdrop is
8 7
…
82 unchanged lines
91 90
- **Parallax.** Three depth layers shift by at most 6 px, 3 px and 1 px
92 91
  against the cursor's offset from the page's centre. That gives a little
93 92
  depth with no feeling of motion sickness.
93 +
- **Streams.** Where a scene shows something travelling (Diffusion's
94 +
  changes, the swarm's chunks), dots move along a gentle arc at 40 px a
95 +
  second, faster than drift, fading in and out at the ends.
94 96
- **Arrivals.** When the page learns of new activity, its mark appears
95 97
  with one soft ring in the brand gradient that widens and fades over
96 98
  1.2 s. This is the one moment a backdrop may draw the eye, and only
…
45 unchanged lines
142 144
notification count), the event joins its cluster with an arrival ring
143 145
(§4). Nothing reloads.
144 146
147 +
## 5.1 The other scenes
148 +
149 +
- **The mark** (sign in, register, device sign-in): 560 dots on the
150 +
  Oxygen mark's two rings, centred on the page, turning opposite ways at
151 +
  9 px a second at the rim, so the stream runs through the middle like
152 +
  the mark's infinity; each ring has its core. Each ring's dots are
153 +
  linked in the order round it. Nothing is named.
154 +
- **Ripples** (a repository's Diffusion tab): the repository in the left
155 +
  margin with its upstreams beside it, its consumers in the right margin,
156 +
  each a ring of 18 dots about a thin-ring core in the tone of its
157 +
  Diffusion pill (the upstream's half for an upstream, the consumer's own
158 +
  half for a consumer). Ten dots a stream flow from each upstream to the
159 +
  repository and from it to each consumer, in the stream's tone, and a
160 +
  ring of 40 dots widens from the repository every 5 s over 3.2 s (never
161 +
  when still). Names: every repository.
162 +
- **The swarm** (Admin › Runners › Images, now in the standard width so it
163 +
  has margins): the hub in the left margin, the runners in the right,
164 +
  each ringed by a dot for every image it holds; while a runner fetches
165 +
  an image, 8 small squares a stream (chunks) flow to it from every
166 +
  holder, the hub's streams bowed one way and peers' the other, up to 40
167 +
  streams. Names: the hub and each runner.
168 +
- **Adrift** (not found, and server errors; never a refusal or a bad
169 +
  request): a cluster of 70 dots in the left margin and one larger mark
170 +
  leaving it up and to the right at 7 px a second, fading after 70 s and
171 +
  setting out again.
172 +
173 +
Every scene is a `Scene` (`crates/backdrop/src/scene.rs`); all but the
174 +
sky share `field.rs`: dots placed by their scene each frame, lit by the
175 +
cursor, joined within a group, named on rest, coloured from the backdrop
176 +
tokens or, where the data has a status, the page's `--ok`, `--warn` and
177 +
`--bad`. The page names its scene in `#backdrop-data`
178 +
(`{"scene": "mark"}`); none means Your sky.
179 +
145 180
## 6. Engine
146 181
147 182
The engine is Rust compiled to WebAssembly (Andy's choice, 2026-10-13):
…
1 unchanged lines
149 184
the scene with no browser in it, tested natively; `gl.rs` draws it;
150 185
`lib.rs` is the browser glue. `make backdrop` builds it for
151 186
`wasm32-unknown-unknown` and runs `wasm-bindgen --target web` into
152 -
`assets/backdrop/` (about 186 KB of wasm, 60 KB gzipped, and the glue),
187 +
`assets/backdrop/` (about 288 KB of wasm, 84 KB gzipped, with every scene, and the glue),
153 188
which is committed and embedded in the hub, as `make ts` commits the API
154 189
types, so neither the hub build nor its offline CI needs the wasm
155 190
toolchain. `make backdrop-check` runs its fmt, clippy (wasm32) and tests.
…
51 unchanged lines
modified templates/admin_images.html +6 −1
1 1
{% extends "base.html" %}
2 2
{% block title %}Images · Administration{% endblock %}
3 -
{% block mainclass %} class="wide"{% endblock %}
4 3
{% block content %}
4 +
{# The swarm (docs/backgrounds.md §2): the hub and the runners, and the
5 +
   images moving between them. #}
6 +
<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
7 +
{% include "backdrop.html" %}
8 +
<div data-backdrop-avoid>
5 9
<div class="head"><h1>Runners <small>{{ images.len()|plural("image", "images") }} · {{ on_hub }} on the hub · <a href="/admin">Administration</a></small></h1></div>
6 10
<div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div>
7 11
<p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p>
…
13 unchanged lines
21 25
  {% endfor %}
22 26
</table></div>
23 27
{% endif %}
28 +
</div>
24 29
{% endblock %}
modified templates/backdrop.html +3 −3
1 -
{# A backdrop (docs/backgrounds.md, #157): the page's `backdrop` data drawn
2 -
   by the engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie;
1 +
{# A backdrop (docs/backgrounds.md): the scene the page's
2 +
   `#backdrop-data` names (written just before this include) drawn by the
3 +
   engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie;
3 4
   unset, a touch-only device or Save-Data gets a still sky. Any failure
4 5
   leaves no backdrop and nothing else changed. #}
5 6
<canvas id="backdrop" aria-hidden="true"></canvas>
6 -
<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
7 7
<script type="module">
8 8
const canvas = document.getElementById("backdrop");
9 9
const chosen = document.cookie.match(/(?:^|; )backdrop=(moving|still|off)/);
…
14 unchanged lines
modified templates/dashboard.html +1 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Dashboard{% endblock %}
3 3
{% block content %}
4 +
<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
4 5
{% include "backdrop.html" %}
5 6
<div class="head">
6 7
  <h1>Dashboard <small>signed in as {% if let Some(u) = shell.user %}{{ u }}{% endif %}</small></h1>
…
91 unchanged lines
modified templates/device.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Sign in a device{% endblock %}
3 3
{% block content %}
4 +
{# The mark (docs/backgrounds.md §2): brand only, no data. #}
5 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
6 +
{% include "backdrop.html" %}
4 7
<div class="head"><h1>Sign in a device <small>an app asked to use your account</small></h1></div>
5 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
6 9
{% if let Some(d) = done %}
…
35 unchanged lines
modified templates/diffusion.html +4 −0
…
4 unchanged lines
5 5
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
6 6
{% block tabs %}{% call m::tabs(repo, "diffusion") %}{% endblock %}
7 7
{% block content %}
8 +
<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
9 +
{% include "backdrop.html" %}
10 +
<div data-backdrop-avoid>
8 11
<div class="head"><h1>Diffusion <small>how changes move between this repository and the ones that pin it</small></h1></div>
9 12
<p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p>
10 13
{% if !used_by.is_empty() %}
…
19 unchanged lines
30 33
{% endif %}
31 34
{% if used_by.is_empty() && follows.is_empty() %}<p class="empty">No repository on this hub pins this one, and it pins none that this hub holds.</p>{% endif %}
32 35
<p class="small mute">Diffusion opens a proposal in a consumer when its upstream moves, and never lands one itself. How each repository follows is under its Settings › Diffusion. <a href="/help#diffusion">How it works</a></p>
36 +
</div>
33 37
{% endblock %}
modified templates/error.html +5 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}{{ title }}{% endblock %}
3 3
{% block content %}
4 +
{% if status == 404 || status >= 500 %}{# Adrift (docs/backgrounds.md §2): no data. #}
5 +
<script type="application/json" id="backdrop-data">{"scene":"adrift"}</script>
6 +
{% include "backdrop.html" %}{% endif %}
7 +
<div data-backdrop-avoid>
4 8
<div class="head"><h1>{{ status }} <small>{{ title }}</small></h1></div>
5 9
<p class="lead">{{ detail }}</p>
6 10
<p><a class="go" href="/">Back to the front page</a></p>
11 +
</div>
7 12
{% endblock %}
modified templates/home.html +2 −1
1 1
{% extends "base.html" %}
2 2
{% block title %}{% if let Some(o) = owner %}{{ o }}{% else %}Repositories{% endif %}{% endblock %}
3 3
{% block content %}
4 -
{% if !backdrop.is_empty() %}{% include "backdrop.html" %}{% endif %}
4 +
{% if !backdrop.is_empty() %}<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
5 +
{% include "backdrop.html" %}{% endif %}
5 6
<div class="head">
6 7
  {% if let Some(o) = owner %}<h1>{{ o }} <small>{{ repos.len()|plural("repository", "repositories") }}</small></h1>
7 8
  {% else %}<h1>Repositories <small>{{ description }}</small></h1>{% endif %}
…
31 unchanged lines
modified templates/login.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Sign in{% endblock %}
3 3
{% block content %}
4 +
{# The mark (docs/backgrounds.md §2): brand only, no data. #}
5 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
6 +
{% include "backdrop.html" %}
4 7
<div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div>
5 8
{% if reset %}<p class="note">Your password is set. Sign in with it; every other session was signed out.</p>{% endif %}
6 9
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
17 unchanged lines
modified templates/register.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Register{% endblock %}
3 3
{% block content %}
4 +
{# The mark (docs/backgrounds.md §2): brand only, no data. #}
5 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
6 +
{% include "backdrop.html" %}
4 7
<div class="head"><h1>Register <small>{% if first %}the first account becomes the administrator{% else %}registration is {{ mode }}{% endif %}</small></h1></div>
5 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
6 9
{% if !first && mode == "closed" %}
…
11 unchanged lines