OXYGENHUB

feat(web): Your sky closer and fuller: 40% larger, tighter knots, the margins filled (refs #157) 6907a989e82a

Marks and orbits are drawn 1.4 times larger with orbit lanes closer
together, so each repository is a tight knot. The clusters now fill the
margins beside the content column (marked data-backdrop-avoid): in the
order of their names' hashes, split between the sides by their widths,
each a staggered grid from top to bottom, instead of a ring that hid some
behind the cards. Narrow margins fall back to the ring.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 18:38 UTC
Signature
✔ signed by Andy McCall
Hash
6907a989e82a4f7554dff2b26b0cd053ff97958eb030b97b62542db1cb0d3bae
Depends on
17ca6cddc534 25ab8c73514a 2f14c5f39e81 df5c69cbd047 f8cf54e99879
Depended on by
ffb67a81188c da8f7826a645 6246d1e51f4f eb56c8bc4d0e
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
26 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified assets/backdrop/backdrop.js +16 −0
…
130 unchanged lines
131 131
            const ret = Array.from(arg0);
132 132
            return ret;
133 133
        },
134 +
        __wbg_getBoundingClientRect_57152b1a20f3de34: function(arg0) {
135 +
            const ret = arg0.getBoundingClientRect();
136 +
            return ret;
137 +
        },
134 138
        __wbg_getComputedStyle_dd0f456804795fd6: function() { return handleError(function (arg0, arg1) {
135 139
            const ret = arg0.getComputedStyle(arg1);
136 140
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
…
87 unchanged lines
224 228
                result = false;
225 229
            }
226 230
            const ret = result;
231 +
            return ret;
232 +
        },
233 +
        __wbg_left_e0a244490fe2f293: function(arg0) {
234 +
            const ret = arg0.left;
227 235
            return ret;
228 236
        },
229 237
        __wbg_length_d4bdea10311bd9cf: function(arg0) {
…
23 unchanged lines
253 261
            const ret = arg0.performance;
254 262
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
255 263
        },
264 +
        __wbg_querySelector_ecbc2550705b235f: function() { return handleError(function (arg0, arg1, arg2) {
265 +
            const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2));
266 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
267 +
        }, arguments); },
256 268
        __wbg_relatedTarget_520fe88b1de28b43: function(arg0) {
257 269
            const ret = arg0.relatedTarget;
258 270
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
…
2 unchanged lines
261 273
            const ret = arg0.requestAnimationFrame(arg1);
262 274
            return ret;
263 275
        }, arguments); },
276 +
        __wbg_right_1dca6be6342de981: function(arg0) {
277 +
            const ret = arg0.right;
278 +
            return ret;
279 +
        },
264 280
        __wbg_setAttribute_9e7d603908f63705: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
265 281
            arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4));
266 282
        }, arguments); },
…
364 unchanged lines
replaced assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

modified crates/backdrop/src/lib.rs +8 −1
…
57 unchanged lines
58 58
        self.ph = (h * dpr).round() as i32;
59 59
        self.canvas.set_width(self.pw.max(1) as u32);
60 60
        self.canvas.set_height(self.ph.max(1) as u32);
61 -
        self.sky.resize(w as f32, h as f32);
61 +
        // The content column, which the page marks: clusters go beside it.
62 +
        let avoid = self
63 +
            .window
64 +
            .document()
65 +
            .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten())
66 +
            .map(|el| el.get_bounding_client_rect())
67 +
            .map(|r| (r.left() as f32, r.right() as f32));
68 +
        self.sky.resize(w as f32, h as f32, avoid);
62 69
    }
63 70
64 71
    /// The page's backdrop tokens, as the browser computes them, so a
…
228 unchanged lines
modified crates/backdrop/src/sky.rs +102 −24
…
8 unchanged lines
9 9
10 10
use std::f32::consts::TAU;
11 11
12 +
/// The scale of marks and orbits: the sky drawn 40% closer than first
13 +
/// built (Andy, 2026-10-13).
14 +
const ZOOM: f32 = 1.4;
12 15
/// How far the cursor lights marks, in CSS pixels (§4).
13 -
pub const LIGHT_RADIUS: f32 = 140.0;
16 +
pub const LIGHT_RADIUS: f32 = 170.0;
14 17
/// Seconds a mark takes to light, and to fade.
15 18
const LIT_IN: f32 = 0.15;
16 19
const LIT_OUT: f32 = 0.6;
…
4 unchanged lines
21 24
/// The longest link: only marks that happened one after another and sit
22 25
/// near each other are joined, so a busy repository reads as a
23 26
/// constellation, not a scribble.
24 -
const LINK_MAX: f32 = 70.0;
27 +
const LINK_MAX: f32 = 85.0;
25 28
/// Links fade when the cursor has rested this long, in seconds.
26 29
const LINK_REST: f32 = 2.0;
27 30
/// A resting cursor this near a cluster's core names it, after this long.
28 -
const LABEL_NEAR: f32 = 90.0;
31 +
const LABEL_NEAR: f32 = 110.0;
29 32
const LABEL_REST: f32 = 0.4;
30 33
/// Events older than this many days are not drawn.
31 34
pub const DAYS: f32 = 30.0;
32 35
/// The most marks one push makes (one a change), and in all.
33 36
const PER_PUSH: u32 = 40;
34 37
const MAX_MARKS: usize = 3000;
38 +
/// A side margin narrower than this holds no clusters: the sky falls back
39 +
/// to a ring around the page.
40 +
const MIN_MARGIN: f32 = 180.0;
41 +
/// The width a cluster gets in a margin's grid.
42 +
const CELL: f32 = 260.0;
35 43
36 44
/// What a mark stands for, which decides its shape.
37 45
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
…
116 unchanged lines
154 162
    /// Still (reduced motion, or the viewer's choice): no drift, no
155 163
    /// parallax, and marks light at once.
156 164
    pub still: bool,
165 +
    /// The page's content column, left and right, in CSS pixels: clusters
166 +
    /// go in the margins beside it, where they can be seen.
167 +
    avoid: Option<(f32, f32)>,
157 168
    parallax: [(f32, f32); 3],
158 169
    link_fade: f32,
159 170
}
…
30 unchanged lines
190 201
        for (pos, &i) in rank.iter().enumerate() {
191 202
            layer[i] = (pos * 3 / repos.len().max(1)).min(2);
192 203
        }
193 -
        let clusters: Vec<Cluster> = repos
194 -
            .iter()
195 -
            .enumerate()
196 -
            .map(|(i, r)| {
197 -
                let h = hash(&r.name);
198 -
                // Around the page, mostly in its margins: a ring from a
199 -
                // quarter to just past half of the view out.
200 -
                let a = unit(h, 0) * TAU;
201 -
                let d = 0.25 + 0.3 * unit(h, 16).sqrt();
202 -
                Cluster {
203 -
                    name: r.name.clone(),
204 -
                    fx: (0.5 + d * a.cos()).clamp(0.03, 0.97),
205 -
                    fy: (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95),
206 -
                    layer: layer[i],
207 -
                }
208 -
            })
209 -
            .collect();
204 +
        let clusters: Vec<Cluster> =
205 +
            repos.iter().enumerate().map(|(i, r)| Cluster { name: r.name.clone(), fx: 0.5, fy: 0.5, layer: layer[i] }).collect();
210 206
        let mut marks: Vec<Mark> = Vec::new();
211 207
        for i in 0..clusters.len() {
212 208
            marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 });
…
19 unchanged lines
232 228
            // Each repository turns one way; its marks keep their lanes, so
233 229
            // two clusters' streams cross without touching.
234 230
            let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 };
235 -
            let radius = 20.0 + (k % lanes) as f32 * 9.0 + unit(h, 0) * 6.0;
231 +
            // Lanes close together: each repository a tight knot.
232 +
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
236 233
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
237 234
            marks.push(Mark {
238 235
                cluster: e.repo,
…
6 unchanged lines
245 242
                lit: 0.0,
246 243
            });
247 244
        }
248 -
        Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, parallax: [(0.0, 0.0); 3], link_fade: 0.0 }
245 +
        let mut sky =
246 +
            Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, avoid: None, parallax: [(0.0, 0.0); 3], link_fade: 0.0 };
247 +
        sky.layout();
248 +
        sky
249 249
    }
250 250
251 -
    pub fn resize(&mut self, w: f32, h: f32) {
251 +
    /// The view is now `w` by `h`, its content column between `avoid`'s
252 +
    /// left and right.
253 +
    pub fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
252 254
        self.w = w;
253 255
        self.h = h;
256 +
        self.avoid = avoid;
257 +
        self.layout();
258 +
    }
259 +
260 +
    /// Place the clusters. In the margins beside the content when both are
261 +
    /// wide enough: repositories in the order of their names' hashes, split
262 +
    /// between the sides by their widths, each side a staggered grid from
263 +
    /// top to bottom, so the sky fills the space the page leaves. Otherwise
264 +
    /// a ring around the middle. The same repositories in the same view
265 +
    /// always land in the same places.
266 +
    fn layout(&mut self) {
267 +
        let n = self.clusters.len();
268 +
        let mut order: Vec<usize> = (0..n).collect();
269 +
        order.sort_by_key(|&i| hash(&self.clusters[i].name));
270 +
        let margins = self.avoid.and_then(|(x0, x1)| {
271 +
            let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
272 +
            (l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1))
273 +
        });
274 +
        match margins {
275 +
            Some((l, r, x1)) => {
276 +
                let left = (((n as f32) * l / (l + r)).round() as usize).min(n);
277 +
                let (a, b) = order.split_at(left);
278 +
                self.grid(a, 0.0, l);
279 +
                self.grid(b, x1, self.w);
280 +
            }
281 +
            None => {
282 +
                for c in &mut self.clusters {
283 +
                    let h = hash(&c.name);
284 +
                    let a = unit(h, 0) * TAU;
285 +
                    let d = 0.25 + 0.3 * unit(h, 16).sqrt();
286 +
                    c.fx = (0.5 + d * a.cos()).clamp(0.03, 0.97);
287 +
                    c.fy = (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95);
288 +
                }
289 +
            }
290 +
        }
254 291
    }
255 292
293 +
    /// Lay `ids` out in a staggered grid between `x0` and `x1`.
294 +
    fn grid(&mut self, ids: &[usize], x0: f32, x1: f32) {
295 +
        if ids.is_empty() || self.w <= 0.0 || self.h <= 0.0 {
296 +
            return;
297 +
        }
298 +
        let (top, bottom) = (self.h * 0.1, self.h * 0.97);
299 +
        let width = x1 - x0;
300 +
        let cols = ((width / CELL) as usize).clamp(1, 3);
301 +
        let rows = ids.len().div_ceil(cols);
302 +
        let (cw, ch) = (width / cols as f32, (bottom - top) / rows as f32);
303 +
        for (j, &i) in ids.iter().enumerate() {
304 +
            let (col, row) = (j % cols, j / cols);
305 +
            let h = hash(&self.clusters[i].name);
306 +
            let stagger = if cols > 1 && row % 2 == 1 { cw * 0.25 } else { 0.0 };
307 +
            let cx = x0 + (col as f32 + 0.5) * cw + stagger + (unit(h, 0) - 0.5) * 0.3 * cw;
308 +
            let cy = top + (row as f32 + 0.5) * ch + (unit(h, 16) - 0.5) * 0.3 * ch;
309 +
            self.clusters[i].fx = cx.clamp(x0 + 24.0, x1 - 24.0) / self.w;
310 +
            self.clusters[i].fy = cy / self.h;
311 +
        }
312 +
    }
313 +
256 314
    /// The cursor moved to `(x, y)`, or left the page.
257 315
    pub fn point(&mut self, at: Option<(f32, f32)>) {
258 316
        self.cursor = at;
…
80 unchanged lines
339 397
            let (x, y) = self.position(m);
340 398
            let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright];
341 399
            let col = mix(rest, c.lit, m.lit);
342 -
            let size = m.kind.size() * (1.0 + 0.35 * m.lit);
400 +
            let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit);
343 401
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
344 402
        }
345 403
    }
…
157 unchanged lines
503 561
            s.step(0.05);
504 562
        }
505 563
        assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen"));
564 +
    }
565 +
566 +
    #[test]
567 +
    fn clusters_fill_the_margins_beside_the_content() {
568 +
        let repos: Vec<Repo> = (0..17).map(|i| Repo { name: format!("owner/repo{i}") }).collect();
569 +
        let mut s = Sky::new(&repos, &[], 2560.0, 1270.0, false);
570 +
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
571 +
        let xs: Vec<f32> = (0..17).map(|i| s.core(i).0).collect();
572 +
        assert!(xs.iter().all(|&x| x < 707.0 || x > 1838.0), "none behind the content: {xs:?}");
573 +
        let left = xs.iter().filter(|&&x| x < 707.0).count();
574 +
        assert!((7..=10).contains(&left), "both sides used: {left} on the left");
575 +
        let ys: Vec<f32> = (0..17).map(|i| s.core(i).1).collect();
576 +
        assert!(ys.iter().any(|&y| y < 400.0) && ys.iter().any(|&y| y > 900.0), "top to bottom: {ys:?}");
577 +
        // The same view lays the same sky out.
578 +
        let mut again = Sky::new(&repos, &[], 2560.0, 1270.0, false);
579 +
        again.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
580 +
        assert_eq!(xs, (0..17).map(|i| again.core(i).0).collect::<Vec<_>>());
581 +
        // Narrow margins: back to the ring.
582 +
        s.resize(1200.0, 800.0, Some((100.0, 1100.0)));
583 +
        assert!((0..17).any(|i| (s.core(i).0 - 600.0).abs() < 400.0));
506 584
    }
507 585
508 586
    #[test]
…
8 unchanged lines
modified docs/backgrounds.md +9 −3
…
116 unchanged lines
117 117
118 118
**Layout.**
119 119
120 -
- Each repository is a cluster, placed by its name's BLAKE3 on a golden
121 -
  angle spiral, so your sky looks the same on every visit and on every
122 -
  device.
120 +
- Each repository is a cluster. The clusters fill the margins beside the
121 +
  content column, which the page marks with `data-backdrop-avoid`: in the
122 +
  order of their names' hashes, split between the two sides by their
123 +
  widths, each side a staggered grid from top to bottom. The same
124 +
  repositories in the same window always land in the same places. Where
125 +
  a margin is under 180 px, they sit on a ring around the page instead.
126 +
- Marks and orbits are drawn at 1.4 times their first size, with orbit
127 +
  lanes close together, so each repository is a tight knot (Andy,
128 +
  2026-10-13).
123 129
- The most active repositories sit in the front layer and the quiet ones
124 130
  further back.
125 131
- A cluster's events are its marks: today's are brightest and a month
…
74 unchanged lines
modified templates/dashboard.html +1 −1
…
26 unchanged lines
27 27
<div class="head">
28 28
  <h1>Dashboard <small>signed in as {% if let Some(u) = shell.user %}{{ u }}{% endif %}</small></h1>
29 29
</div>
30 -
<div class="side-layout rev dash">
30 +
<div class="side-layout rev dash" data-backdrop-avoid>
31 31
  <div>
32 32
    <section class="card-box">
33 33
    <div class="card-head"><h2>Waiting for your review</h2><span class="count-chip">{{ reviews.len() }}</span></div>
…
86 unchanged lines