OXYGENHUB

feat(web): Backdrops, the engine in Rust and WASM, and the dashboard's Your sky (refs #157) 17ca6cddc534

crates/backdrop (a workspace of its own) draws a sky with WebGL2: each of
the viewer's repositories a cluster placed by a hash of its name, its last
30 days of pushes, proposals, issues and releases orbiting it as dots,
diamonds, rings and stars, brightest when newest. The cursor lights marks
near it without moving them, joins one repository's lit marks in the order
they happened, names a cluster it rests by, and shifts three depth layers
a few pixels. Still under reduced motion or by choice; paused when hidden,
half rate when the cursor rests, stopped after two idle minutes.

make backdrop writes assets/backdrop/ (186 KB of wasm, 60 KB gzipped),
committed and embedded, so the hub build and CI need no wasm toolchain.
The dashboard embeds its data from the database alone; the --bd-* tokens
are in both looks and themes, checked to rest under 1.3:1; Settings ›
Appearance chooses Moving, Still or Off.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 18:01 UTC
Signature
✔ signed by Andy McCall
Hash
17ca6cddc5347fbcfb0c04ff8e95bf25f41b3f4931d8702cda847e37608a982d
Depends on
046bd71a180f 145306044896 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 34beb1a7e376 749d9a5fcd9f 756fe44a0f4d 8e820b4ae875 93be9ce4653e b64e2537d702 c40c2d3edb51 d23464c0ac60 d98a93c10a0d f3e5e2bc78e9 f8cf54e99879
Depended on by
1062a0f8e985 4dfa4599b3f7 8a48dd830178 a7aa76ac52d7 9a8e3b4419e0 ffb67a81188c da8f7826a645 5501b1c657b2 f993f792537e 6246d1e51f4f 0a3b244b748f 7f283f8e8b97 eb56c8bc4d0e 6907a989e82a 33bd0bd45f8a df5c69cbd047
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
40 atoms, 10 tree operations, 18 files

Browse the tree at this change

added assets/backdrop/backdrop.js +630 −0
1 +
/**
2 +
 * Start the dashboard's sky on `canvas` with the page's `data`. `still`
3 +
 * is the viewer's choice of a still backdrop; reduced motion asks for
4 +
 * one too. Without WebGL2 there is no backdrop, and nothing breaks.
5 +
 * @param {HTMLCanvasElement} canvas
6 +
 * @param {any} data
7 +
 * @param {boolean} still
8 +
 */
9 +
export function start(canvas, data, still) {
10 +
    const ret = wasm.start(canvas, data, still);
11 +
    if (ret[1]) {
12 +
        throw takeFromExternrefTable0(ret[0]);
13 +
    }
14 +
}
15 +
function __wbg_get_imports() {
16 +
    const import0 = {
17 +
        __proto__: null,
18 +
        __wbg___wbindgen_boolean_get_5b446f51afd21013: function(arg0) {
19 +
            const v = arg0;
20 +
            const ret = typeof(v) === 'boolean' ? v : undefined;
21 +
            return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
22 +
        },
23 +
        __wbg___wbindgen_is_undefined_8865fb403f8fe9d8: function(arg0) {
24 +
            const ret = arg0 === undefined;
25 +
            return ret;
26 +
        },
27 +
        __wbg___wbindgen_number_get_2e0e7dee9f701a71: function(arg0, arg1) {
28 +
            const obj = arg1;
29 +
            const ret = typeof(obj) === 'number' ? obj : undefined;
30 +
            getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
31 +
            getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
32 +
        },
33 +
        __wbg___wbindgen_string_get_0380ccaa2f57f0d9: function(arg0, arg1) {
34 +
            const obj = arg1;
35 +
            const ret = typeof(obj) === 'string' ? obj : undefined;
36 +
            var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
37 +
            var len1 = WASM_VECTOR_LEN;
38 +
            getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
39 +
            getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
40 +
        },
41 +
        __wbg___wbindgen_throw_41e9ee4f547fc59a: function(arg0, arg1) {
42 +
            throw new Error(getStringFromWasm0(arg0, arg1));
43 +
        },
44 +
        __wbg__wbg_cb_unref_dcc1a90847f04c41: function(arg0) {
45 +
            arg0._wbg_cb_unref();
46 +
        },
47 +
        __wbg_addEventListener_4d0db17c671ea324: function() { return handleError(function (arg0, arg1, arg2, arg3) {
48 +
            arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3);
49 +
        }, arguments); },
50 +
        __wbg_appendChild_fb8c52e7dd8484ea: function() { return handleError(function (arg0, arg1) {
51 +
            const ret = arg0.appendChild(arg1);
52 +
            return ret;
53 +
        }, arguments); },
54 +
        __wbg_attachShader_9567ae57af6746d5: function(arg0, arg1, arg2) {
55 +
            arg0.attachShader(arg1, arg2);
56 +
        },
57 +
        __wbg_bindBuffer_0324f62b876c4403: function(arg0, arg1, arg2) {
58 +
            arg0.bindBuffer(arg1 >>> 0, arg2);
59 +
        },
60 +
        __wbg_bindVertexArray_f58af0a346185ec5: function(arg0, arg1) {
61 +
            arg0.bindVertexArray(arg1);
62 +
        },
63 +
        __wbg_blendFunc_589e4252bb396484: function(arg0, arg1, arg2) {
64 +
            arg0.blendFunc(arg1 >>> 0, arg2 >>> 0);
65 +
        },
66 +
        __wbg_body_e549239eaff082e1: function(arg0) {
67 +
            const ret = arg0.body;
68 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
69 +
        },
70 +
        __wbg_bufferData_5fa44d94fe7a3878: function(arg0, arg1, arg2, arg3) {
71 +
            arg0.bufferData(arg1 >>> 0, arg2, arg3 >>> 0);
72 +
        },
73 +
        __wbg_clearColor_796427e8ac60fe14: function(arg0, arg1, arg2, arg3, arg4) {
74 +
            arg0.clearColor(arg1, arg2, arg3, arg4);
75 +
        },
76 +
        __wbg_clear_cb96796cc568b7c8: function(arg0, arg1) {
77 +
            arg0.clear(arg1 >>> 0);
78 +
        },
79 +
        __wbg_clientX_5075231ed45e8ef5: function(arg0) {
80 +
            const ret = arg0.clientX;
81 +
            return ret;
82 +
        },
83 +
        __wbg_clientY_846c39814ce02ab2: function(arg0) {
84 +
            const ret = arg0.clientY;
85 +
            return ret;
86 +
        },
87 +
        __wbg_compileShader_c2b53ff3a8851f60: function(arg0, arg1) {
88 +
            arg0.compileShader(arg1);
89 +
        },
90 +
        __wbg_createBuffer_ffe613371d42cfd3: function(arg0) {
91 +
            const ret = arg0.createBuffer();
92 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
93 +
        },
94 +
        __wbg_createElement_74049073a11f9c31: function() { return handleError(function (arg0, arg1, arg2) {
95 +
            const ret = arg0.createElement(getStringFromWasm0(arg1, arg2));
96 +
            return ret;
97 +
        }, arguments); },
98 +
        __wbg_createProgram_cbf448e6f18a9402: function(arg0) {
99 +
            const ret = arg0.createProgram();
100 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
101 +
        },
102 +
        __wbg_createShader_6c1a2295587878a5: function(arg0, arg1) {
103 +
            const ret = arg0.createShader(arg1 >>> 0);
104 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
105 +
        },
106 +
        __wbg_createVertexArray_b223ea69dd08fcab: function(arg0) {
107 +
            const ret = arg0.createVertexArray();
108 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
109 +
        },
110 +
        __wbg_devicePixelRatio_7d39e9af5448d3d4: function(arg0) {
111 +
            const ret = arg0.devicePixelRatio;
112 +
            return ret;
113 +
        },
114 +
        __wbg_document_9854e03c05fc8834: function(arg0) {
115 +
            const ret = arg0.document;
116 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
117 +
        },
118 +
        __wbg_drawArraysInstanced_9b44aa78a087154d: function(arg0, arg1, arg2, arg3, arg4) {
119 +
            arg0.drawArraysInstanced(arg1 >>> 0, arg2, arg3, arg4);
120 +
        },
121 +
        __wbg_drawArrays_08872e02fb57b847: function(arg0, arg1, arg2, arg3) {
122 +
            arg0.drawArrays(arg1 >>> 0, arg2, arg3);
123 +
        },
124 +
        __wbg_enableVertexAttribArray_d52693866bc8b8dd: function(arg0, arg1) {
125 +
            arg0.enableVertexAttribArray(arg1 >>> 0);
126 +
        },
127 +
        __wbg_enable_cb4fc19b51148c37: function(arg0, arg1) {
128 +
            arg0.enable(arg1 >>> 0);
129 +
        },
130 +
        __wbg_from_296ca31f8d0f1c52: function(arg0) {
131 +
            const ret = Array.from(arg0);
132 +
            return ret;
133 +
        },
134 +
        __wbg_getComputedStyle_dd0f456804795fd6: function() { return handleError(function (arg0, arg1) {
135 +
            const ret = arg0.getComputedStyle(arg1);
136 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
137 +
        }, arguments); },
138 +
        __wbg_getContext_051324720aa5b7e8: function() { return handleError(function (arg0, arg1, arg2, arg3) {
139 +
            const ret = arg0.getContext(getStringFromWasm0(arg1, arg2), arg3);
140 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
141 +
        }, arguments); },
142 +
        __wbg_getProgramInfoLog_3276b294d6d42fe2: function(arg0, arg1, arg2) {
143 +
            const ret = arg1.getProgramInfoLog(arg2);
144 +
            var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
145 +
            var len1 = WASM_VECTOR_LEN;
146 +
            getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
147 +
            getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
148 +
        },
149 +
        __wbg_getProgramParameter_fc7124bb9fb7c675: function(arg0, arg1, arg2) {
150 +
            const ret = arg0.getProgramParameter(arg1, arg2 >>> 0);
151 +
            return ret;
152 +
        },
153 +
        __wbg_getPropertyValue_e90a3628c2de4fb6: function() { return handleError(function (arg0, arg1, arg2, arg3) {
154 +
            const ret = arg1.getPropertyValue(getStringFromWasm0(arg2, arg3));
155 +
            const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
156 +
            const len1 = WASM_VECTOR_LEN;
157 +
            getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
158 +
            getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
159 +
        }, arguments); },
160 +
        __wbg_getShaderInfoLog_7e0cbba353ca95da: function(arg0, arg1, arg2) {
161 +
            const ret = arg1.getShaderInfoLog(arg2);
162 +
            var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
163 +
            var len1 = WASM_VECTOR_LEN;
164 +
            getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
165 +
            getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
166 +
        },
167 +
        __wbg_getShaderParameter_745acf10b8e45e31: function(arg0, arg1, arg2) {
168 +
            const ret = arg0.getShaderParameter(arg1, arg2 >>> 0);
169 +
            return ret;
170 +
        },
171 +
        __wbg_getUniformLocation_50ba45dade572703: function(arg0, arg1, arg2, arg3) {
172 +
            const ret = arg0.getUniformLocation(arg1, getStringFromWasm0(arg2, arg3));
173 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
174 +
        },
175 +
        __wbg_get_31af05bd4842a84f: function() { return handleError(function (arg0, arg1) {
176 +
            const ret = Reflect.get(arg0, arg1);
177 +
            return ret;
178 +
        }, arguments); },
179 +
        __wbg_get_6c896e0571ddae51: function(arg0, arg1) {
180 +
            const ret = arg0[arg1 >>> 0];
181 +
            return ret;
182 +
        },
183 +
        __wbg_get_unchecked_288889d017702237: function(arg0, arg1) {
184 +
            const ret = arg0[arg1 >>> 0];
185 +
            return ret;
186 +
        },
187 +
        __wbg_hidden_2ba371ecafe013fa: function(arg0) {
188 +
            const ret = arg0.hidden;
189 +
            return ret;
190 +
        },
191 +
        __wbg_innerHeight_7f20e0f1a7c5a573: function() { return handleError(function (arg0) {
192 +
            const ret = arg0.innerHeight;
193 +
            return ret;
194 +
        }, arguments); },
195 +
        __wbg_innerWidth_1bc526614080537c: function() { return handleError(function (arg0) {
196 +
            const ret = arg0.innerWidth;
197 +
            return ret;
198 +
        }, arguments); },
199 +
        __wbg_instanceof_HtmlElement_32fb153a5a0e2349: function(arg0) {
200 +
            let result;
201 +
            try {
202 +
                result = arg0 instanceof HTMLElement;
203 +
            } catch (_) {
204 +
                result = false;
205 +
            }
206 +
            const ret = result;
207 +
            return ret;
208 +
        },
209 +
        __wbg_instanceof_WebGl2RenderingContext_64c994c14c0790fb: function(arg0) {
210 +
            let result;
211 +
            try {
212 +
                result = arg0 instanceof WebGL2RenderingContext;
213 +
            } catch (_) {
214 +
                result = false;
215 +
            }
216 +
            const ret = result;
217 +
            return ret;
218 +
        },
219 +
        __wbg_instanceof_Window_82d71df4eddf88bc: function(arg0) {
220 +
            let result;
221 +
            try {
222 +
                result = arg0 instanceof Window;
223 +
            } catch (_) {
224 +
                result = false;
225 +
            }
226 +
            const ret = result;
227 +
            return ret;
228 +
        },
229 +
        __wbg_length_d4bdea10311bd9cf: function(arg0) {
230 +
            const ret = arg0.length;
231 +
            return ret;
232 +
        },
233 +
        __wbg_linkProgram_acdc299c43d734a0: function(arg0, arg1) {
234 +
            arg0.linkProgram(arg1);
235 +
        },
236 +
        __wbg_matchMedia_8a4857f947f11f82: function() { return handleError(function (arg0, arg1, arg2) {
237 +
            const ret = arg0.matchMedia(getStringFromWasm0(arg1, arg2));
238 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
239 +
        }, arguments); },
240 +
        __wbg_matches_778d0873b064b1bf: function(arg0) {
241 +
            const ret = arg0.matches;
242 +
            return ret;
243 +
        },
244 +
        __wbg_new_617a8cdb8bb1130e: function() {
245 +
            const ret = new Object();
246 +
            return ret;
247 +
        },
248 +
        __wbg_now_d0b7f4bea9f38490: function(arg0) {
249 +
            const ret = arg0.now();
250 +
            return ret;
251 +
        },
252 +
        __wbg_performance_d0d7b03fc649b694: function(arg0) {
253 +
            const ret = arg0.performance;
254 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
255 +
        },
256 +
        __wbg_relatedTarget_520fe88b1de28b43: function(arg0) {
257 +
            const ret = arg0.relatedTarget;
258 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
259 +
        },
260 +
        __wbg_requestAnimationFrame_7b526ab7aa550c74: function() { return handleError(function (arg0, arg1) {
261 +
            const ret = arg0.requestAnimationFrame(arg1);
262 +
            return ret;
263 +
        }, arguments); },
264 +
        __wbg_setAttribute_9e7d603908f63705: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
265 +
            arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4));
266 +
        }, arguments); },
267 +
        __wbg_setProperty_097bc3d55ce44513: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
268 +
            arg0.setProperty(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4));
269 +
        }, arguments); },
270 +
        __wbg_set_145a351398b48c65: function() { return handleError(function (arg0, arg1, arg2) {
271 +
            const ret = Reflect.set(arg0, arg1, arg2);
272 +
            return ret;
273 +
        }, arguments); },
274 +
        __wbg_set_className_541fce5cd31918aa: function(arg0, arg1, arg2) {
275 +
            arg0.className = getStringFromWasm0(arg1, arg2);
276 +
        },
277 +
        __wbg_set_height_c9789c1c77eaedff: function(arg0, arg1) {
278 +
            arg0.height = arg1 >>> 0;
279 +
        },
280 +
        __wbg_set_textContent_6d6fc559f198055f: function(arg0, arg1, arg2) {
281 +
            arg0.textContent = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2);
282 +
        },
283 +
        __wbg_set_width_b0e1267db4b196b5: function(arg0, arg1) {
284 +
            arg0.width = arg1 >>> 0;
285 +
        },
286 +
        __wbg_shaderSource_c8812639b47d87b9: function(arg0, arg1, arg2, arg3) {
287 +
            arg0.shaderSource(arg1, getStringFromWasm0(arg2, arg3));
288 +
        },
289 +
        __wbg_static_accessor_GLOBAL_266715b9d96ba635: function() {
290 +
            const ret = typeof global === 'undefined' ? null : global;
291 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
292 +
        },
293 +
        __wbg_static_accessor_GLOBAL_THIS_10fb7dc1ae063179: function() {
294 +
            const ret = typeof globalThis === 'undefined' ? null : globalThis;
295 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
296 +
        },
297 +
        __wbg_static_accessor_SELF_0b583911f537483a: function() {
298 +
            const ret = typeof self === 'undefined' ? null : self;
299 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
300 +
        },
301 +
        __wbg_static_accessor_WINDOW_d7f903d1508cbdc4: function() {
302 +
            const ret = typeof window === 'undefined' ? null : window;
303 +
            return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
304 +
        },
305 +
        __wbg_style_4bce24230e493a7c: function(arg0) {
306 +
            const ret = arg0.style;
307 +
            return ret;
308 +
        },
309 +
        __wbg_uniform2f_a92b5bb95dc5b4d9: function(arg0, arg1, arg2, arg3) {
310 +
            arg0.uniform2f(arg1, arg2, arg3);
311 +
        },
312 +
        __wbg_useProgram_e075c2902e420761: function(arg0, arg1) {
313 +
            arg0.useProgram(arg1);
314 +
        },
315 +
        __wbg_vertexAttribDivisor_7575d382215322f7: function(arg0, arg1, arg2) {
316 +
            arg0.vertexAttribDivisor(arg1 >>> 0, arg2 >>> 0);
317 +
        },
318 +
        __wbg_vertexAttribPointer_24b495e18df325dc: function(arg0, arg1, arg2, arg3, arg4, arg5, arg6) {
319 +
            arg0.vertexAttribPointer(arg1 >>> 0, arg2, arg3 >>> 0, arg4 !== 0, arg5, arg6);
320 +
        },
321 +
        __wbg_viewport_16936692526e2ce6: function(arg0, arg1, arg2, arg3, arg4) {
322 +
            arg0.viewport(arg1, arg2, arg3, arg4);
323 +
        },
324 +
        __wbindgen_generic_0000000000000001: function(arg0, arg1) {
325 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [F64], shim_idx: 6, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
326 +
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___f64______true_);
327 +
            return ret;
328 +
        },
329 +
        __wbindgen_generic_0000000000000002: function(arg0, arg1) {
330 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 5, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
331 +
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___web_sys_793942649ef82f98___features__gen_MouseEvent__MouseEvent______true_);
332 +
            return ret;
333 +
        },
334 +
        __wbindgen_generic_0000000000000003: function(arg0, arg1) {
335 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 4, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
336 +
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke_______true_);
337 +
            return ret;
338 +
        },
339 +
        __wbindgen_generic_0000000000000004: function(arg0, arg1) {
340 +
            // Cast intrinsic for `Ref(Slice(F32)) -> NamedExternref("Float32Array")`.
341 +
            const ret = getArrayF32FromWasm0(arg0, arg1);
342 +
            return ret;
343 +
        },
344 +
        __wbindgen_generic_0000000000000005: function(arg0, arg1) {
345 +
            // Cast intrinsic for `Ref(String) -> Externref`.
346 +
            const ret = getStringFromWasm0(arg0, arg1);
347 +
            return ret;
348 +
        },
349 +
        __wbindgen_init_externref_table: function() {
350 +
            const table = wasm.__wbindgen_externrefs;
351 +
            const offset = table.grow(4);
352 +
            table.set(0, undefined);
353 +
            table.set(offset + 0, undefined);
354 +
            table.set(offset + 1, null);
355 +
            table.set(offset + 2, true);
356 +
            table.set(offset + 3, false);
357 +
        },
358 +
    };
359 +
    return {
360 +
        __proto__: null,
361 +
        "./backdrop_bg.js": import0,
362 +
    };
363 +
}
364 +
365 +
function wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke_______true_(arg0, arg1) {
366 +
    wasm.wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke_______true_(arg0, arg1);
367 +
}
368 +
369 +
function wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___web_sys_793942649ef82f98___features__gen_MouseEvent__MouseEvent______true_(arg0, arg1, arg2) {
370 +
    wasm.wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___web_sys_793942649ef82f98___features__gen_MouseEvent__MouseEvent______true_(arg0, arg1, arg2);
371 +
}
372 +
373 +
function wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___f64______true_(arg0, arg1, arg2) {
374 +
    wasm.wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___f64______true_(arg0, arg1, arg2);
375 +
}
376 +
377 +
function addToExternrefTable0(obj) {
378 +
    const idx = wasm.__externref_table_alloc();
379 +
    wasm.__wbindgen_externrefs.set(idx, obj);
380 +
    return idx;
381 +
}
382 +
383 +
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
384 +
    ? { register: () => {}, unregister: () => {} }
385 +
    : new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));
386 +
387 +
function getArrayF32FromWasm0(ptr, len) {
388 +
    ptr = ptr >>> 0;
389 +
    return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
390 +
}
391 +
392 +
let cachedDataViewMemory0 = null;
393 +
function getDataViewMemory0() {
394 +
    if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
395 +
        cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
396 +
    }
397 +
    return cachedDataViewMemory0;
398 +
}
399 +
400 +
let cachedFloat32ArrayMemory0 = null;
401 +
function getFloat32ArrayMemory0() {
402 +
    if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) {
403 +
        cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer);
404 +
    }
405 +
    return cachedFloat32ArrayMemory0;
406 +
}
407 +
408 +
function getStringFromWasm0(ptr, len) {
409 +
    return decodeText(ptr >>> 0, len);
410 +
}
411 +
412 +
let cachedUint8ArrayMemory0 = null;
413 +
function getUint8ArrayMemory0() {
414 +
    if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
415 +
        cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
416 +
    }
417 +
    return cachedUint8ArrayMemory0;
418 +
}
419 +
420 +
function handleError(f, args) {
421 +
    try {
422 +
        return f.apply(this, args);
423 +
    } catch (e) {
424 +
        const idx = addToExternrefTable0(e);
425 +
        wasm.__wbindgen_exn_store(idx);
426 +
    }
427 +
}
428 +
429 +
function isLikeNone(x) {
430 +
    return x === undefined || x === null;
431 +
}
432 +
433 +
function makeMutClosure(arg0, arg1, f) {
434 +
    const state = { a: arg0, b: arg1, cnt: 1 };
435 +
    const real = (...args) => {
436 +
437 +
        // First up with a closure we increment the internal reference
438 +
        // count. This ensures that the Rust closure environment won't
439 +
        // be deallocated while we're invoking it.
440 +
        state.cnt++;
441 +
        const a = state.a;
442 +
        state.a = 0;
443 +
        try {
444 +
            return f(a, state.b, ...args);
445 +
        } finally {
446 +
            state.a = a;
447 +
            real._wbg_cb_unref();
448 +
        }
449 +
    };
450 +
    real._wbg_cb_unref = () => {
451 +
        if (--state.cnt === 0) {
452 +
            wasm.__wbindgen_destroy_closure(state.a, state.b);
453 +
            state.a = 0;
454 +
            CLOSURE_DTORS.unregister(state);
455 +
        }
456 +
    };
457 +
    CLOSURE_DTORS.register(real, state, state);
458 +
    return real;
459 +
}
460 +
461 +
function passStringToWasm0(arg, malloc, realloc) {
462 +
    if (realloc === undefined) {
463 +
        const buf = cachedTextEncoder.encode(arg);
464 +
        const ptr = malloc(buf.length, 1) >>> 0;
465 +
        getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
466 +
        WASM_VECTOR_LEN = buf.length;
467 +
        return ptr;
468 +
    }
469 +
470 +
    let len = arg.length;
471 +
    let ptr = malloc(len, 1) >>> 0;
472 +
473 +
    const mem = getUint8ArrayMemory0();
474 +
475 +
    let offset = 0;
476 +
477 +
    for (; offset < len; offset++) {
478 +
        const code = arg.charCodeAt(offset);
479 +
        if (code > 0x7F) break;
480 +
        mem[ptr + offset] = code;
481 +
    }
482 +
    if (offset !== len) {
483 +
        if (offset !== 0) {
484 +
            arg = arg.slice(offset);
485 +
        }
486 +
        ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
487 +
        const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
488 +
        const ret = cachedTextEncoder.encodeInto(arg, view);
489 +
490 +
        offset += ret.written;
491 +
        ptr = realloc(ptr, len, offset, 1) >>> 0;
492 +
    }
493 +
494 +
    WASM_VECTOR_LEN = offset;
495 +
    return ptr;
496 +
}
497 +
498 +
function takeFromExternrefTable0(idx) {
499 +
    const value = wasm.__wbindgen_externrefs.get(idx);
500 +
    wasm.__externref_table_dealloc(idx);
501 +
    return value;
502 +
}
503 +
504 +
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
505 +
cachedTextDecoder.decode();
506 +
const MAX_SAFARI_DECODE_BYTES = 2146435072;
507 +
let numBytesDecoded = 0;
508 +
function decodeText(ptr, len) {
509 +
    numBytesDecoded += len;
510 +
    if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
511 +
        cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
512 +
        cachedTextDecoder.decode();
513 +
        numBytesDecoded = len;
514 +
    }
515 +
    return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
516 +
}
517 +
518 +
const cachedTextEncoder = new TextEncoder();
519 +
520 +
if (!('encodeInto' in cachedTextEncoder)) {
521 +
    cachedTextEncoder.encodeInto = function (arg, view) {
522 +
        const buf = cachedTextEncoder.encode(arg);
523 +
        view.set(buf);
524 +
        return {
525 +
            read: arg.length,
526 +
            written: buf.length
527 +
        };
528 +
    };
529 +
}
530 +
531 +
let WASM_VECTOR_LEN = 0;
532 +
533 +
let wasmModule, wasmInstance, wasm;
534 +
function __wbg_finalize_init(instance, module) {
535 +
    wasmInstance = instance;
536 +
    wasm = instance.exports;
537 +
    wasmModule = module;
538 +
    cachedDataViewMemory0 = null;
539 +
    cachedFloat32ArrayMemory0 = null;
540 +
    cachedUint8ArrayMemory0 = null;
541 +
    wasm.__wbindgen_start();
542 +
    return wasm;
543 +
}
544 +
545 +
async function __wbg_load(module, imports) {
546 +
    if (typeof Response === 'function' && module instanceof Response) {
547 +
        if (!module.ok) {
548 +
            throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
549 +
        }
550 +
551 +
        if (typeof WebAssembly.instantiateStreaming === 'function') {
552 +
            try {
553 +
                return await WebAssembly.instantiateStreaming(module, imports);
554 +
            } catch (e) {
555 +
                const validResponse = expectedResponseType(module.type);
556 +
557 +
                if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
558 +
                    console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
559 +
560 +
                } else { throw e; }
561 +
            }
562 +
        }
563 +
564 +
        const bytes = await module.arrayBuffer();
565 +
        return await WebAssembly.instantiate(bytes, imports);
566 +
    } else {
567 +
        const instance = await WebAssembly.instantiate(module, imports);
568 +
569 +
        if (instance instanceof WebAssembly.Instance) {
570 +
            return { instance, module };
571 +
        } else {
572 +
            return instance;
573 +
        }
574 +
    }
575 +
576 +
    function expectedResponseType(type) {
577 +
        switch (type) {
578 +
            case 'basic': case 'cors': case 'default': return true;
579 +
        }
580 +
        return false;
581 +
    }
582 +
}
583 +
584 +
function initSync(module) {
585 +
    if (wasm !== undefined) return wasm;
586 +
587 +
588 +
    if (module !== undefined) {
589 +
        if (Object.getPrototypeOf(module) === Object.prototype) {
590 +
            ({module} = module)
591 +
        } else {
592 +
            console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
593 +
        }
594 +
    }
595 +
596 +
    const imports = __wbg_get_imports();
597 +
    if (!(module instanceof WebAssembly.Module)) {
598 +
        module = new WebAssembly.Module(module);
599 +
    }
600 +
    const instance = new WebAssembly.Instance(module, imports);
601 +
    return __wbg_finalize_init(instance, module);
602 +
}
603 +
604 +
async function __wbg_init(module_or_path) {
605 +
    if (wasm !== undefined) return wasm;
606 +
607 +
608 +
    if (module_or_path !== undefined) {
609 +
        if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
610 +
            ({module_or_path} = module_or_path)
611 +
        } else {
612 +
            console.warn('using deprecated parameters for the initialization function; pass a single object instead')
613 +
        }
614 +
    }
615 +
616 +
    if (module_or_path === undefined) {
617 +
        module_or_path = new URL('backdrop_bg.wasm', import.meta.url);
618 +
    }
619 +
    const imports = __wbg_get_imports();
620 +
621 +
    if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
622 +
        module_or_path = fetch(module_or_path);
623 +
    }
624 +
625 +
    const { instance, module } = await __wbg_load(await module_or_path, imports);
626 +
627 +
    return __wbg_finalize_init(instance, module);
628 +
}
629 +
630 +
export { initSync, __wbg_init as default };
added assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

added crates/backdrop/Cargo.lock +173 −0
1 +
# This file is automatically @generated by Cargo.
2 +
# It is not intended for manual editing.
3 +
version = 4
4 +
5 +
[[package]]
6 +
name = "backdrop"
7 +
version = "0.1.0"
8 +
dependencies = [
9 +
 "js-sys",
10 +
 "wasm-bindgen",
11 +
 "web-sys",
12 +
]
13 +
14 +
[[package]]
15 +
name = "bumpalo"
16 +
version = "3.20.3"
17 +
source = "registry+https://github.com/rust-lang/crates.io-index"
18 +
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
19 +
20 +
[[package]]
21 +
name = "cfg-if"
22 +
version = "1.0.5"
23 +
source = "registry+https://github.com/rust-lang/crates.io-index"
24 +
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
25 +
26 +
[[package]]
27 +
name = "futures-core"
28 +
version = "0.3.34"
29 +
source = "registry+https://github.com/rust-lang/crates.io-index"
30 +
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
31 +
32 +
[[package]]
33 +
name = "futures-task"
34 +
version = "0.3.34"
35 +
source = "registry+https://github.com/rust-lang/crates.io-index"
36 +
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
37 +
38 +
[[package]]
39 +
name = "futures-util"
40 +
version = "0.3.34"
41 +
source = "registry+https://github.com/rust-lang/crates.io-index"
42 +
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
43 +
dependencies = [
44 +
 "futures-core",
45 +
 "futures-task",
46 +
 "pin-project-lite",
47 +
 "slab",
48 +
]
49 +
50 +
[[package]]
51 +
name = "js-sys"
52 +
version = "0.3.106"
53 +
source = "registry+https://github.com/rust-lang/crates.io-index"
54 +
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
55 +
dependencies = [
56 +
 "cfg-if",
57 +
 "futures-util",
58 +
 "wasm-bindgen",
59 +
]
60 +
61 +
[[package]]
62 +
name = "once_cell"
63 +
version = "1.21.4"
64 +
source = "registry+https://github.com/rust-lang/crates.io-index"
65 +
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
66 +
67 +
[[package]]
68 +
name = "pin-project-lite"
69 +
version = "0.2.17"
70 +
source = "registry+https://github.com/rust-lang/crates.io-index"
71 +
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
72 +
73 +
[[package]]
74 +
name = "proc-macro2"
75 +
version = "1.0.107"
76 +
source = "registry+https://github.com/rust-lang/crates.io-index"
77 +
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
78 +
dependencies = [
79 +
 "unicode-ident",
80 +
]
81 +
82 +
[[package]]
83 +
name = "quote"
84 +
version = "1.0.47"
85 +
source = "registry+https://github.com/rust-lang/crates.io-index"
86 +
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
87 +
dependencies = [
88 +
 "proc-macro2",
89 +
]
90 +
91 +
[[package]]
92 +
name = "rustversion"
93 +
version = "1.0.23"
94 +
source = "registry+https://github.com/rust-lang/crates.io-index"
95 +
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
96 +
97 +
[[package]]
98 +
name = "slab"
99 +
version = "0.4.12"
100 +
source = "registry+https://github.com/rust-lang/crates.io-index"
101 +
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
102 +
103 +
[[package]]
104 +
name = "syn"
105 +
version = "3.0.9"
106 +
source = "registry+https://github.com/rust-lang/crates.io-index"
107 +
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
108 +
dependencies = [
109 +
 "proc-macro2",
110 +
 "quote",
111 +
 "unicode-ident",
112 +
]
113 +
114 +
[[package]]
115 +
name = "unicode-ident"
116 +
version = "1.0.27"
117 +
source = "registry+https://github.com/rust-lang/crates.io-index"
118 +
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
119 +
120 +
[[package]]
121 +
name = "wasm-bindgen"
122 +
version = "0.2.129"
123 +
source = "registry+https://github.com/rust-lang/crates.io-index"
124 +
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
125 +
dependencies = [
126 +
 "cfg-if",
127 +
 "once_cell",
128 +
 "rustversion",
129 +
 "wasm-bindgen-macro",
130 +
 "wasm-bindgen-shared",
131 +
]
132 +
133 +
[[package]]
134 +
name = "wasm-bindgen-macro"
135 +
version = "0.2.129"
136 +
source = "registry+https://github.com/rust-lang/crates.io-index"
137 +
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
138 +
dependencies = [
139 +
 "quote",
140 +
 "wasm-bindgen-macro-support",
141 +
]
142 +
143 +
[[package]]
144 +
name = "wasm-bindgen-macro-support"
145 +
version = "0.2.129"
146 +
source = "registry+https://github.com/rust-lang/crates.io-index"
147 +
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
148 +
dependencies = [
149 +
 "bumpalo",
150 +
 "proc-macro2",
151 +
 "quote",
152 +
 "syn",
153 +
 "wasm-bindgen-shared",
154 +
]
155 +
156 +
[[package]]
157 +
name = "wasm-bindgen-shared"
158 +
version = "0.2.129"
159 +
source = "registry+https://github.com/rust-lang/crates.io-index"
160 +
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
161 +
dependencies = [
162 +
 "unicode-ident",
163 +
]
164 +
165 +
[[package]]
166 +
name = "web-sys"
167 +
version = "0.3.106"
168 +
source = "registry+https://github.com/rust-lang/crates.io-index"
169 +
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
170 +
dependencies = [
171 +
 "js-sys",
172 +
 "wasm-bindgen",
173 +
]
added crates/backdrop/Cargo.toml +48 −0
1 +
# The backdrop engine (docs/backgrounds.md): Rust compiled to WebAssembly,
2 +
# drawing with WebGL2. A workspace of its own, outside the hub's, so the hub
3 +
# build and its offline CI never need the wasm toolchain: `make backdrop`
4 +
# builds it and writes assets/backdrop/, which the hub embeds.
5 +
[package]
6 +
name = "backdrop"
7 +
version = "0.1.0"
8 +
edition = "2021"
9 +
license = "MIT"
10 +
publish = false
11 +
12 +
[lib]
13 +
crate-type = ["cdylib", "rlib"]
14 +
15 +
[dependencies]
16 +
wasm-bindgen = "0.2"
17 +
js-sys = "0.3"
18 +
19 +
[dependencies.web-sys]
20 +
version = "0.3"
21 +
features = [
22 +
    "CssStyleDeclaration",
23 +
    "Document",
24 +
    "DomRect",
25 +
    "Element",
26 +
    "Event",
27 +
    "EventTarget",
28 +
    "HtmlCanvasElement",
29 +
    "HtmlElement",
30 +
    "MediaQueryList",
31 +
    "MouseEvent",
32 +
    "Performance",
33 +
    "WebGl2RenderingContext",
34 +
    "WebGlBuffer",
35 +
    "WebGlProgram",
36 +
    "WebGlShader",
37 +
    "WebGlUniformLocation",
38 +
    "WebGlVertexArrayObject",
39 +
    "Window",
40 +
]
41 +
42 +
[profile.release]
43 +
opt-level = "z"
44 +
lto = true
45 +
codegen-units = 1
46 +
panic = "abort"
47 +
48 +
[workspace]
added crates/backdrop/src/gl.rs +195 −0
1 +
//! Drawing a sky with WebGL2 (docs/backgrounds.md §6): every mark in one
2 +
//! instanced draw, every link in one line buffer. Shaders are here as
3 +
//! string literals; marks are drawn as signed-distance shapes, so they
4 +
//! stay crisp at any device pixel ratio.
5 +
6 +
use wasm_bindgen::prelude::*;
7 +
use web_sys::{WebGl2RenderingContext as Gl, WebGlBuffer, WebGlProgram, WebGlShader, WebGlUniformLocation, WebGlVertexArrayObject};
8 +
9 +
const MARK_VS: &str = r#"#version 300 es
10 +
layout(location = 0) in vec2 corner;
11 +
layout(location = 1) in vec2 center;
12 +
layout(location = 2) in float size;
13 +
layout(location = 3) in float shape;
14 +
layout(location = 4) in vec4 colour;
15 +
uniform vec2 view;
16 +
out vec2 uv;
17 +
out float vshape;
18 +
out vec4 vcolour;
19 +
out float px;
20 +
void main() {
21 +
    float r = size + 1.5;
22 +
    vec2 p = center + corner * r;
23 +
    uv = corner * r / size;
24 +
    vshape = shape;
25 +
    vcolour = colour;
26 +
    px = 1.0 / size;
27 +
    gl_Position = vec4(p.x / view.x * 2.0 - 1.0, 1.0 - p.y / view.y * 2.0, 0.0, 1.0);
28 +
}
29 +
"#;
30 +
31 +
const MARK_FS: &str = r#"#version 300 es
32 +
precision mediump float;
33 +
in vec2 uv;
34 +
in float vshape;
35 +
in vec4 vcolour;
36 +
in float px;
37 +
out vec4 frag;
38 +
void main() {
39 +
    vec2 a = abs(uv);
40 +
    float d;
41 +
    if (vshape < 0.5) {
42 +
        d = length(uv) - 1.0;                       // a push: a dot
43 +
    } else if (vshape < 1.5) {
44 +
        d = (a.x + a.y) * 0.85 - 1.0;               // a proposal: a diamond
45 +
    } else if (vshape < 2.5) {
46 +
        d = abs(length(uv) - 0.72) - 0.28;          // an issue: a ring
47 +
    } else if (vshape < 3.5) {
48 +
        d = min(a.x + 3.5 * a.y, 3.5 * a.x + a.y) * 0.5 - 0.5;  // a release: a star
49 +
    } else {
50 +
        d = abs(length(uv) - 0.85) - 0.12;          // a repository: a thin ring
51 +
    }
52 +
    float cover = 1.0 - smoothstep(-px, px, d);
53 +
    frag = vcolour * cover;
54 +
}
55 +
"#;
56 +
57 +
const LINE_VS: &str = r#"#version 300 es
58 +
layout(location = 0) in vec2 pos;
59 +
layout(location = 1) in vec4 colour;
60 +
uniform vec2 view;
61 +
out vec4 vcolour;
62 +
void main() {
63 +
    vcolour = colour;
64 +
    gl_Position = vec4(pos.x / view.x * 2.0 - 1.0, 1.0 - pos.y / view.y * 2.0, 0.0, 1.0);
65 +
}
66 +
"#;
67 +
68 +
const LINE_FS: &str = r#"#version 300 es
69 +
precision mediump float;
70 +
in vec4 vcolour;
71 +
out vec4 frag;
72 +
void main() { frag = vcolour; }
73 +
"#;
74 +
75 +
/// Floats per mark instance and per line vertex (sky::Sky's layouts).
76 +
const MARK_FLOATS: i32 = 8;
77 +
const LINE_FLOATS: i32 = 6;
78 +
79 +
pub struct Renderer {
80 +
    gl: Gl,
81 +
    marks: WebGlProgram,
82 +
    lines: WebGlProgram,
83 +
    mark_view: Option<WebGlUniformLocation>,
84 +
    line_view: Option<WebGlUniformLocation>,
85 +
    mark_vao: WebGlVertexArrayObject,
86 +
    line_vao: WebGlVertexArrayObject,
87 +
    instances: WebGlBuffer,
88 +
    line_buf: WebGlBuffer,
89 +
}
90 +
91 +
fn shader(gl: &Gl, kind: u32, src: &str) -> Result<WebGlShader, JsValue> {
92 +
    let s = gl.create_shader(kind).ok_or("no shader")?;
93 +
    gl.shader_source(&s, src);
94 +
    gl.compile_shader(&s);
95 +
    if gl.get_shader_parameter(&s, Gl::COMPILE_STATUS).as_bool() == Some(true) {
96 +
        Ok(s)
97 +
    } else {
98 +
        Err(gl.get_shader_info_log(&s).unwrap_or_default().into())
99 +
    }
100 +
}
101 +
102 +
fn program(gl: &Gl, vs: &str, fs: &str) -> Result<WebGlProgram, JsValue> {
103 +
    let p = gl.create_program().ok_or("no program")?;
104 +
    gl.attach_shader(&p, &shader(gl, Gl::VERTEX_SHADER, vs)?);
105 +
    gl.attach_shader(&p, &shader(gl, Gl::FRAGMENT_SHADER, fs)?);
106 +
    gl.link_program(&p);
107 +
    if gl.get_program_parameter(&p, Gl::LINK_STATUS).as_bool() == Some(true) {
108 +
        Ok(p)
109 +
    } else {
110 +
        Err(gl.get_program_info_log(&p).unwrap_or_default().into())
111 +
    }
112 +
}
113 +
114 +
fn float_attrib(gl: &Gl, loc: u32, size: i32, stride: i32, offset: i32, divisor: u32) {
115 +
    gl.enable_vertex_attrib_array(loc);
116 +
    gl.vertex_attrib_pointer_with_i32(loc, size, Gl::FLOAT, false, stride * 4, offset * 4);
117 +
    gl.vertex_attrib_divisor(loc, divisor);
118 +
}
119 +
120 +
impl Renderer {
121 +
    pub fn new(gl: Gl) -> Result<Renderer, JsValue> {
122 +
        let marks = program(&gl, MARK_VS, MARK_FS)?;
123 +
        let lines = program(&gl, LINE_VS, LINE_FS)?;
124 +
        let mark_view = gl.get_uniform_location(&marks, "view");
125 +
        let line_view = gl.get_uniform_location(&lines, "view");
126 +
127 +
        // Marks: one shared quad, and one instance per mark.
128 +
        let mark_vao = gl.create_vertex_array().ok_or("no vertex array")?;
129 +
        gl.bind_vertex_array(Some(&mark_vao));
130 +
        let quad = gl.create_buffer().ok_or("no buffer")?;
131 +
        gl.bind_buffer(Gl::ARRAY_BUFFER, Some(&quad));
132 +
        let corners: [f32; 8] = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
133 +
        // Safety: the view is used at once, before anything can allocate.
134 +
        unsafe {
135 +
            let view = js_sys::Float32Array::view(&corners);
136 +
            gl.buffer_data_with_array_buffer_view(Gl::ARRAY_BUFFER, &view, Gl::STATIC_DRAW);
137 +
        }
138 +
        float_attrib(&gl, 0, 2, 2, 0, 0);
139 +
        let instances = gl.create_buffer().ok_or("no buffer")?;
140 +
        gl.bind_buffer(Gl::ARRAY_BUFFER, Some(&instances));
141 +
        float_attrib(&gl, 1, 2, MARK_FLOATS, 0, 1);
142 +
        float_attrib(&gl, 2, 1, MARK_FLOATS, 2, 1);
143 +
        float_attrib(&gl, 3, 1, MARK_FLOATS, 3, 1);
144 +
        float_attrib(&gl, 4, 4, MARK_FLOATS, 4, 1);
145 +
146 +
        // Links: plain lines, two vertices each.
147 +
        let line_vao = gl.create_vertex_array().ok_or("no vertex array")?;
148 +
        gl.bind_vertex_array(Some(&line_vao));
149 +
        let line_buf = gl.create_buffer().ok_or("no buffer")?;
150 +
        gl.bind_buffer(Gl::ARRAY_BUFFER, Some(&line_buf));
151 +
        float_attrib(&gl, 0, 2, LINE_FLOATS, 0, 0);
152 +
        float_attrib(&gl, 1, 4, LINE_FLOATS, 2, 0);
153 +
        gl.bind_vertex_array(None);
154 +
155 +
        gl.enable(Gl::BLEND);
156 +
        // Colours are premultiplied.
157 +
        gl.blend_func(Gl::ONE, Gl::ONE_MINUS_SRC_ALPHA);
158 +
        Ok(Renderer { gl, marks, lines, mark_view, line_view, mark_vao, line_vao, instances, line_buf })
159 +
    }
160 +
161 +
    /// Draw one frame: `instances` and `lines` as sky::Sky lays them out,
162 +
    /// in a view of `w` by `h` CSS pixels on a canvas of `pw` by `ph`.
163 +
    pub fn draw(&self, w: f32, h: f32, pw: i32, ph: i32, instances: &[f32], lines: &[f32]) {
164 +
        let gl = &self.gl;
165 +
        gl.viewport(0, 0, pw, ph);
166 +
        gl.clear_color(0.0, 0.0, 0.0, 0.0);
167 +
        gl.clear(Gl::COLOR_BUFFER_BIT);
168 +
169 +
        if !lines.is_empty() {
170 +
            gl.use_program(Some(&self.lines));
171 +
            gl.uniform2f(self.line_view.as_ref(), w, h);
172 +
            gl.bind_vertex_array(Some(&self.line_vao));
173 +
            gl.bind_buffer(Gl::ARRAY_BUFFER, Some(&self.line_buf));
174 +
            // Safety: as above, the view does not outlive this call.
175 +
            unsafe {
176 +
                let view = js_sys::Float32Array::view(lines);
177 +
                gl.buffer_data_with_array_buffer_view(Gl::ARRAY_BUFFER, &view, Gl::STREAM_DRAW);
178 +
            }
179 +
            gl.draw_arrays(Gl::LINES, 0, lines.len() as i32 / LINE_FLOATS);
180 +
        }
181 +
182 +
        if !instances.is_empty() {
183 +
            gl.use_program(Some(&self.marks));
184 +
            gl.uniform2f(self.mark_view.as_ref(), w, h);
185 +
            gl.bind_vertex_array(Some(&self.mark_vao));
186 +
            gl.bind_buffer(Gl::ARRAY_BUFFER, Some(&self.instances));
187 +
            unsafe {
188 +
                let view = js_sys::Float32Array::view(instances);
189 +
                gl.buffer_data_with_array_buffer_view(Gl::ARRAY_BUFFER, &view, Gl::STREAM_DRAW);
190 +
            }
191 +
            gl.draw_arrays_instanced(Gl::TRIANGLE_STRIP, 0, 4, instances.len() as i32 / MARK_FLOATS);
192 +
        }
193 +
        gl.bind_vertex_array(None);
194 +
    }
195 +
}
added crates/backdrop/src/lib.rs +290 −0
1 +
//! Backdrops (docs/backgrounds.md): the quiet, moving pictures behind
2 +
//! some of OxygenHub's pages, in Rust compiled to WebAssembly and drawn
3 +
//! with WebGL2. `sky` is the scene and its motion, with no browser in it;
4 +
//! `gl` draws it; this module is the browser around them: the frame loop,
5 +
//! the cursor, the window's size, the page's colours and when to stop.
6 +
7 +
pub mod gl;
8 +
pub mod sky;
9 +
10 +
use sky::{Colours, Event, Kind, Repo, Sky};
11 +
use std::cell::RefCell;
12 +
use std::rc::Rc;
13 +
use wasm_bindgen::prelude::*;
14 +
use wasm_bindgen::JsCast;
15 +
use web_sys::{HtmlCanvasElement, HtmlElement, WebGl2RenderingContext, Window};
16 +
17 +
/// The highest device pixel ratio drawn at: sharp enough, and cheap.
18 +
const MAX_DPR: f64 = 1.5;
19 +
/// After this long without input a moving backdrop draws its last frame
20 +
/// and stops until the next movement (§6).
21 +
const STOP_AFTER_S: f64 = 120.0;
22 +
/// A still cursor drops the frame rate to half after this long.
23 +
const HALF_RATE_AFTER_S: f64 = 3.0;
24 +
25 +
struct Engine {
26 +
    window: Window,
27 +
    canvas: HtmlCanvasElement,
28 +
    renderer: gl::Renderer,
29 +
    sky: Sky,
30 +
    colours: Colours,
31 +
    colours_at: f64,
32 +
    probe: HtmlElement,
33 +
    label: HtmlElement,
34 +
    label_text: String,
35 +
    inst: Vec<f32>,
36 +
    lines: Vec<f32>,
37 +
    last: Option<f64>,
38 +
    frame: u64,
39 +
    running: bool,
40 +
    input_at: f64,
41 +
    pw: i32,
42 +
    ph: i32,
43 +
}
44 +
45 +
type Frame = Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>>;
46 +
47 +
impl Engine {
48 +
    fn now(&self) -> f64 {
49 +
        self.window.performance().map(|p| p.now()).unwrap_or(0.0)
50 +
    }
51 +
52 +
    /// Fit the canvas to the window.
53 +
    fn fit(&mut self) {
54 +
        let w = self.window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
55 +
        let h = self.window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
56 +
        let dpr = self.window.device_pixel_ratio().clamp(1.0, MAX_DPR);
57 +
        self.pw = (w * dpr).round() as i32;
58 +
        self.ph = (h * dpr).round() as i32;
59 +
        self.canvas.set_width(self.pw.max(1) as u32);
60 +
        self.canvas.set_height(self.ph.max(1) as u32);
61 +
        self.sky.resize(w as f32, h as f32);
62 +
    }
63 +
64 +
    /// The page's backdrop tokens, as the browser computes them, so a
65 +
    /// theme switch shows within a second.
66 +
    fn read_colours(&mut self) {
67 +
        let read = |token: &str, fallback: sky::Rgba| -> sky::Rgba {
68 +
            let _ = self.probe.style().set_property("color", &format!("var({token})"));
69 +
            self.window
70 +
                .get_computed_style(&self.probe)
71 +
                .ok()
72 +
                .flatten()
73 +
                .and_then(|s| s.get_property_value("color").ok())
74 +
                .and_then(|c| sky::parse_colour(&c))
75 +
                .unwrap_or(fallback)
76 +
        };
77 +
        self.colours = Colours {
78 +
            mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]),
79 +
            lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]),
80 +
            link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]),
81 +
        };
82 +
    }
83 +
84 +
    /// One animation frame at time `t`; whether another is wanted.
85 +
    fn tick(&mut self, t: f64) -> bool {
86 +
        if self.window.document().is_some_and(|d| d.hidden()) {
87 +
            self.last = None;
88 +
            return false;
89 +
        }
90 +
        let idle = (t - self.input_at) / 1000.0;
91 +
        self.frame += 1;
92 +
        if !self.sky.still && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 {
93 +
            return true;
94 +
        }
95 +
        let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0);
96 +
        self.last = Some(t);
97 +
        if t - self.colours_at > 1000.0 {
98 +
            self.read_colours();
99 +
            self.colours_at = t;
100 +
        }
101 +
        self.sky.step(dt);
102 +
        self.sky.instances(&self.colours, &mut self.inst);
103 +
        self.sky.links(&self.colours, &mut self.lines);
104 +
        self.renderer.draw(self.sky.w, self.sky.h, self.pw, self.ph, &self.inst, &self.lines);
105 +
        self.show_label();
106 +
        if self.sky.still {
107 +
            self.sky.busy()
108 +
        } else {
109 +
            idle < STOP_AFTER_S || self.sky.busy()
110 +
        }
111 +
    }
112 +
113 +
    /// The name beside a resting cursor (§4): faded in and out by CSS.
114 +
    fn show_label(&mut self) {
115 +
        let next = self.sky.label().map(|(name, x, y)| (name.to_string(), x, y));
116 +
        let style = self.label.style();
117 +
        match next {
118 +
            Some((name, x, y)) => {
119 +
                if name != self.label_text {
120 +
                    self.label.set_text_content(Some(&name));
121 +
                    self.label_text = name;
122 +
                }
123 +
                let _ = style.set_property("left", &format!("{}px", (x + 12.0) as i32));
124 +
                let _ = style.set_property("top", &format!("{}px", (y - 8.0) as i32));
125 +
                let _ = style.set_property("opacity", "1");
126 +
            }
127 +
            None => {
128 +
                let _ = style.set_property("opacity", "0");
129 +
            }
130 +
        }
131 +
    }
132 +
}
133 +
134 +
/// Ask for a frame unless the loop is already running.
135 +
fn wake(engine: &Rc<RefCell<Engine>>, frame: &Frame) {
136 +
    let mut e = engine.borrow_mut();
137 +
    if e.running {
138 +
        return;
139 +
    }
140 +
    e.running = true;
141 +
    if let Some(f) = frame.borrow().as_ref() {
142 +
        let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
143 +
    }
144 +
}
145 +
146 +
fn get(v: &JsValue, key: &str) -> JsValue {
147 +
    js_sys::Reflect::get(v, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED)
148 +
}
149 +
150 +
/// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms]]}`.
151 +
fn read_data(data: &JsValue) -> (Vec<Repo>, Vec<Event>) {
152 +
    let now = get(data, "now").as_f64().unwrap_or(0.0);
153 +
    let repos: Vec<Repo> =
154 +
        js_sys::Array::from(&get(data, "repos")).iter().filter_map(|v| v.as_string()).map(|name| Repo { name }).collect();
155 +
    let events: Vec<Event> = js_sys::Array::from(&get(data, "events"))
156 +
        .iter()
157 +
        .filter_map(|v| {
158 +
            let e = js_sys::Array::from(&v);
159 +
            let repo = e.get(0).as_f64()? as usize;
160 +
            let kind = Kind::from_code(e.get(1).as_f64()? as u32);
161 +
            let at = e.get(2).as_f64()?;
162 +
            Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32 })
163 +
        })
164 +
        .collect();
165 +
    (repos, events)
166 +
}
167 +
168 +
/// Start the dashboard's sky on `canvas` with the page's `data`. `still`
169 +
/// is the viewer's choice of a still backdrop; reduced motion asks for
170 +
/// one too. Without WebGL2 there is no backdrop, and nothing breaks.
171 +
#[wasm_bindgen]
172 +
pub fn start(canvas: HtmlCanvasElement, data: JsValue, still: bool) -> Result<(), JsValue> {
173 +
    let window = web_sys::window().ok_or("no window")?;
174 +
    let document = window.document().ok_or("no document")?;
175 +
    let reduced = window.match_media("(prefers-reduced-motion: reduce)").ok().flatten().is_some_and(|m| m.matches());
176 +
    let still = still || reduced;
177 +
178 +
    let options = js_sys::Object::new();
179 +
    js_sys::Reflect::set(&options, &"alpha".into(), &true.into())?;
180 +
    js_sys::Reflect::set(&options, &"premultipliedAlpha".into(), &true.into())?;
181 +
    js_sys::Reflect::set(&options, &"antialias".into(), &false.into())?;
182 +
    js_sys::Reflect::set(&options, &"powerPreference".into(), &"low-power".into())?;
183 +
    let gl: WebGl2RenderingContext = canvas.get_context_with_context_options("webgl2", &options)?.ok_or("no WebGL2")?.dyn_into()?;
184 +
    let renderer = gl::Renderer::new(gl)?;
185 +
186 +
    let body = document.body().ok_or("no body")?;
187 +
    let probe: HtmlElement = document.create_element("span")?.dyn_into()?;
188 +
    probe.set_attribute("style", "position:fixed;visibility:hidden;pointer-events:none")?;
189 +
    probe.set_attribute("aria-hidden", "true")?;
190 +
    body.append_child(&probe)?;
191 +
    let label: HtmlElement = document.create_element("div")?.dyn_into()?;
192 +
    label.set_class_name("bd-label");
193 +
    label.set_attribute("aria-hidden", "true")?;
194 +
    body.append_child(&label)?;
195 +
196 +
    let (repos, events) = read_data(&data);
197 +
    let sky = Sky::new(&repos, &events, 0.0, 0.0, still);
198 +
    let colours = Colours { mark: [0.0; 4], lit: [0.0; 4], link: [0.0; 4] };
199 +
    let engine = Rc::new(RefCell::new(Engine {
200 +
        window: window.clone(),
201 +
        canvas,
202 +
        renderer,
203 +
        sky,
204 +
        colours,
205 +
        colours_at: f64::NEG_INFINITY,
206 +
        probe,
207 +
        label,
208 +
        label_text: String::new(),
209 +
        inst: Vec::new(),
210 +
        lines: Vec::new(),
211 +
        last: None,
212 +
        frame: 0,
213 +
        running: false,
214 +
        input_at: 0.0,
215 +
        pw: 0,
216 +
        ph: 0,
217 +
    }));
218 +
    {
219 +
        let mut e = engine.borrow_mut();
220 +
        e.fit();
221 +
        e.input_at = e.now();
222 +
    }
223 +
224 +
    // The frame loop: each frame asks for the next while it is wanted.
225 +
    let frame: Frame = Rc::new(RefCell::new(None));
226 +
    {
227 +
        let (engine, again) = (engine.clone(), frame.clone());
228 +
        *frame.borrow_mut() = Some(Closure::new(move |t: f64| {
229 +
            let more = engine.borrow_mut().tick(t);
230 +
            let mut e = engine.borrow_mut();
231 +
            if more {
232 +
                if let Some(f) = again.borrow().as_ref() {
233 +
                    let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
234 +
                }
235 +
            } else {
236 +
                e.running = false;
237 +
            }
238 +
        }));
239 +
    }
240 +
241 +
    // The cursor lights marks; it never moves them.
242 +
    {
243 +
        let (engine, frame) = (engine.clone(), frame.clone());
244 +
        let moved = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
245 +
            {
246 +
                let mut e = engine.borrow_mut();
247 +
                e.sky.point(Some((ev.client_x() as f32, ev.client_y() as f32)));
248 +
                e.input_at = e.now();
249 +
            }
250 +
            wake(&engine, &frame);
251 +
        });
252 +
        window.add_event_listener_with_callback("mousemove", moved.as_ref().unchecked_ref())?;
253 +
        moved.forget();
254 +
    }
255 +
    {
256 +
        let (engine, frame) = (engine.clone(), frame.clone());
257 +
        let left = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
258 +
            if ev.related_target().is_none() {
259 +
                engine.borrow_mut().sky.point(None);
260 +
                wake(&engine, &frame);
261 +
            }
262 +
        });
263 +
        document.add_event_listener_with_callback("mouseout", left.as_ref().unchecked_ref())?;
264 +
        left.forget();
265 +
    }
266 +
    {
267 +
        let (engine, frame) = (engine.clone(), frame.clone());
268 +
        let resized = Closure::<dyn FnMut()>::new(move || {
269 +
            engine.borrow_mut().fit();
270 +
            wake(&engine, &frame);
271 +
        });
272 +
        window.add_event_listener_with_callback("resize", resized.as_ref().unchecked_ref())?;
273 +
        resized.forget();
274 +
    }
275 +
    {
276 +
        let (engine, frame) = (engine.clone(), frame.clone());
277 +
        let shown = Closure::<dyn FnMut()>::new(move || {
278 +
            let visible = engine.borrow().window.document().is_some_and(|d| !d.hidden());
279 +
            if visible {
280 +
                engine.borrow_mut().last = None;
281 +
                wake(&engine, &frame);
282 +
            }
283 +
        });
284 +
        document.add_event_listener_with_callback("visibilitychange", shown.as_ref().unchecked_ref())?;
285 +
        shown.forget();
286 +
    }
287 +
288 +
    wake(&engine, &frame);
289 +
    Ok(())
290 +
}
added crates/backdrop/src/sky.rs +486 −0
1 +
//! Your sky (docs/backgrounds.md §5): the dashboard's scene, its layout
2 +
//! and its motion, with no browser in it so it is tested natively.
3 +
//!
4 +
//! Each repository is a cluster placed by a hash of its name, so a
5 +
//! viewer's sky is the same on every visit; its events of the last 30 days
6 +
//! orbit it as marks, brightest when newest, shaped by kind. The cursor
7 +
//! lights marks near it without moving them, and joins the lit marks of
8 +
//! one repository in the order they happened.
9 +
10 +
use std::f32::consts::TAU;
11 +
12 +
/// How far the cursor lights marks, in CSS pixels (§4).
13 +
pub const LIGHT_RADIUS: f32 = 140.0;
14 +
/// Seconds a mark takes to light, and to fade.
15 +
const LIT_IN: f32 = 0.15;
16 +
const LIT_OUT: f32 = 0.6;
17 +
/// The fastest a mark drifts, in CSS pixels a second.
18 +
pub const MAX_SPEED: f32 = 12.0;
19 +
/// How far each depth layer shifts against the cursor, front to back.
20 +
const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0];
21 +
/// Links fade when the cursor has rested this long, in seconds.
22 +
const LINK_REST: f32 = 2.0;
23 +
/// A resting cursor this near a cluster's core names it, after this long.
24 +
const LABEL_NEAR: f32 = 90.0;
25 +
const LABEL_REST: f32 = 0.4;
26 +
/// Events older than this many days are not drawn.
27 +
pub const DAYS: f32 = 30.0;
28 +
29 +
/// What a mark stands for, which decides its shape.
30 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31 +
pub enum Kind {
32 +
    Push,
33 +
    Proposal,
34 +
    Issue,
35 +
    Release,
36 +
    /// The repository itself, its cluster's core.
37 +
    Repo,
38 +
}
39 +
40 +
impl Kind {
41 +
    /// The code the page's data uses: 0 push, 1 proposal, 2 issue,
42 +
    /// 3 release.
43 +
    pub fn from_code(code: u32) -> Kind {
44 +
        match code {
45 +
            1 => Kind::Proposal,
46 +
            2 => Kind::Issue,
47 +
            3 => Kind::Release,
48 +
            _ => Kind::Push,
49 +
        }
50 +
    }
51 +
52 +
    /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring,
53 +
    /// 3 four-point star, 4 thin ring (a repository).
54 +
    pub fn shape(self) -> f32 {
55 +
        match self {
56 +
            Kind::Push => 0.0,
57 +
            Kind::Proposal => 1.0,
58 +
            Kind::Issue => 2.0,
59 +
            Kind::Release => 3.0,
60 +
            Kind::Repo => 4.0,
61 +
        }
62 +
    }
63 +
64 +
    /// Its radius in CSS pixels.
65 +
    fn size(self) -> f32 {
66 +
        match self {
67 +
            Kind::Push => 2.2,
68 +
            Kind::Proposal => 3.2,
69 +
            Kind::Issue => 3.0,
70 +
            Kind::Release => 4.2,
71 +
            Kind::Repo => 6.0,
72 +
        }
73 +
    }
74 +
}
75 +
76 +
/// One repository, as the page gives it.
77 +
#[derive(Clone, Debug)]
78 +
pub struct Repo {
79 +
    pub name: String,
80 +
}
81 +
82 +
/// One event, as the page gives it: its repository (an index into the
83 +
/// repositories), its kind and how old it is.
84 +
#[derive(Clone, Copy, Debug)]
85 +
pub struct Event {
86 +
    pub repo: usize,
87 +
    pub kind: Kind,
88 +
    pub age_days: f32,
89 +
}
90 +
91 +
/// A repository's place: a fraction of the view, and a depth layer.
92 +
#[derive(Clone, Debug)]
93 +
pub struct Cluster {
94 +
    pub name: String,
95 +
    pub fx: f32,
96 +
    pub fy: f32,
97 +
    pub layer: usize,
98 +
}
99 +
100 +
/// One drawn mark, orbiting its cluster.
101 +
#[derive(Clone, Debug)]
102 +
pub struct Mark {
103 +
    pub cluster: usize,
104 +
    pub kind: Kind,
105 +
    /// Its place in its cluster's history, 0 the newest; the core is 0
106 +
    /// too and has radius 0.
107 +
    pub order: u32,
108 +
    pub radius: f32,
109 +
    pub angle: f32,
110 +
    /// Radians a second, signed.
111 +
    pub speed: f32,
112 +
    /// 1 for today, fading to 0.3 for a month ago.
113 +
    pub bright: f32,
114 +
    /// How lit it is now, 0 to 1.
115 +
    pub lit: f32,
116 +
}
117 +
118 +
/// Straight RGBA, 0 to 1, as the page's tokens give them.
119 +
pub type Rgba = [f32; 4];
120 +
121 +
/// The three backdrop tokens (§3).
122 +
#[derive(Clone, Copy, Debug, PartialEq)]
123 +
pub struct Colours {
124 +
    pub mark: Rgba,
125 +
    pub lit: Rgba,
126 +
    pub link: Rgba,
127 +
}
128 +
129 +
/// The scene.
130 +
pub struct Sky {
131 +
    pub clusters: Vec<Cluster>,
132 +
    pub marks: Vec<Mark>,
133 +
    /// The view, in CSS pixels.
134 +
    pub w: f32,
135 +
    pub h: f32,
136 +
    /// Where the cursor is, if it is over the page.
137 +
    pub cursor: Option<(f32, f32)>,
138 +
    /// Seconds since the cursor last moved.
139 +
    pub rested: f32,
140 +
    /// Still (reduced motion, or the viewer's choice): no drift, no
141 +
    /// parallax, and marks light at once.
142 +
    pub still: bool,
143 +
    parallax: [(f32, f32); 3],
144 +
    link_fade: f32,
145 +
}
146 +
147 +
/// FNV-1a: a small, stable hash, so a repository's place never changes.
148 +
pub fn hash(s: &str) -> u64 {
149 +
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
150 +
    for b in s.as_bytes() {
151 +
        h ^= *b as u64;
152 +
        h = h.wrapping_mul(0x0100_0000_01b3);
153 +
    }
154 +
    h
155 +
}
156 +
157 +
/// A number from 0 to 1 from bits of a hash.
158 +
fn unit(h: u64, shift: u32) -> f32 {
159 +
    ((h >> shift) & 0xffff) as f32 / 65536.0
160 +
}
161 +
162 +
impl Sky {
163 +
    /// The sky of `repos` and their `events` (newest first, as the page
164 +
    /// lists them), in a view of `w` by `h` CSS pixels.
165 +
    pub fn new(repos: &[Repo], events: &[Event], w: f32, h: f32, still: bool) -> Sky {
166 +
        // The most active repositories in front, the quiet ones behind.
167 +
        let mut activity = vec![0u32; repos.len()];
168 +
        for e in events {
169 +
            if e.repo < repos.len() && e.age_days <= DAYS {
170 +
                activity[e.repo] += 1;
171 +
            }
172 +
        }
173 +
        let mut rank: Vec<usize> = (0..repos.len()).collect();
174 +
        rank.sort_by_key(|&i| std::cmp::Reverse(activity[i]));
175 +
        let mut layer = vec![2usize; repos.len()];
176 +
        for (pos, &i) in rank.iter().enumerate() {
177 +
            layer[i] = (pos * 3 / repos.len().max(1)).min(2);
178 +
        }
179 +
        let clusters: Vec<Cluster> = repos
180 +
            .iter()
181 +
            .enumerate()
182 +
            .map(|(i, r)| {
183 +
                let h = hash(&r.name);
184 +
                // Around the page, mostly in its margins: a ring from a
185 +
                // quarter to just past half of the view out.
186 +
                let a = unit(h, 0) * TAU;
187 +
                let d = 0.25 + 0.3 * unit(h, 16).sqrt();
188 +
                Cluster {
189 +
                    name: r.name.clone(),
190 +
                    fx: (0.5 + d * a.cos()).clamp(0.03, 0.97),
191 +
                    fy: (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95),
192 +
                    layer: layer[i],
193 +
                }
194 +
            })
195 +
            .collect();
196 +
        let mut marks: Vec<Mark> = Vec::new();
197 +
        for i in 0..clusters.len() {
198 +
            marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 });
199 +
        }
200 +
        let mut seen = vec![0u32; repos.len()];
201 +
        for e in events {
202 +
            if e.repo >= repos.len() || e.age_days > DAYS {
203 +
                continue;
204 +
            }
205 +
            let k = seen[e.repo];
206 +
            seen[e.repo] += 1;
207 +
            let h = hash(&format!("{}#{k}", repos[e.repo].name));
208 +
            // Each repository turns one way; its marks keep their lanes, so
209 +
            // two clusters' streams cross without touching.
210 +
            let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 };
211 +
            let radius = 22.0 + (k % 6) as f32 * 11.0 + unit(h, 0) * 6.0;
212 +
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
213 +
            marks.push(Mark {
214 +
                cluster: e.repo,
215 +
                kind: e.kind,
216 +
                order: k,
217 +
                radius,
218 +
                angle: unit(h, 32) * TAU,
219 +
                speed,
220 +
                bright: 1.0 - 0.7 * (e.age_days / DAYS).clamp(0.0, 1.0),
221 +
                lit: 0.0,
222 +
            });
223 +
        }
224 +
        Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, parallax: [(0.0, 0.0); 3], link_fade: 0.0 }
225 +
    }
226 +
227 +
    pub fn resize(&mut self, w: f32, h: f32) {
228 +
        self.w = w;
229 +
        self.h = h;
230 +
    }
231 +
232 +
    /// The cursor moved to `(x, y)`, or left the page.
233 +
    pub fn point(&mut self, at: Option<(f32, f32)>) {
234 +
        self.cursor = at;
235 +
        self.rested = 0.0;
236 +
    }
237 +
238 +
    /// Where cluster `c`'s core is now.
239 +
    pub fn core(&self, c: usize) -> (f32, f32) {
240 +
        let cl = &self.clusters[c];
241 +
        let (px, py) = self.parallax[cl.layer];
242 +
        (cl.fx * self.w + px, cl.fy * self.h + py)
243 +
    }
244 +
245 +
    /// Where mark `m` is now.
246 +
    pub fn position(&self, m: &Mark) -> (f32, f32) {
247 +
        let (cx, cy) = self.core(m.cluster);
248 +
        (cx + m.angle.cos() * m.radius, cy + m.angle.sin() * m.radius)
249 +
    }
250 +
251 +
    /// Move the scene on by `dt` seconds.
252 +
    pub fn step(&mut self, dt: f32) {
253 +
        let dt = dt.clamp(0.0, 0.1);
254 +
        self.rested += dt;
255 +
        if !self.still {
256 +
            for m in &mut self.marks {
257 +
                m.angle = (m.angle + m.speed * dt) % TAU;
258 +
            }
259 +
            let ease = 1.0 - (-dt * 4.0).exp();
260 +
            for (l, p) in self.parallax.iter_mut().enumerate() {
261 +
                let (tx, ty) = match self.cursor {
262 +
                    Some((x, y)) if self.w > 0.0 && self.h > 0.0 => {
263 +
                        (-(x - self.w / 2.0) / (self.w / 2.0) * PARALLAX[l], -(y - self.h / 2.0) / (self.h / 2.0) * PARALLAX[l])
264 +
                    }
265 +
                    _ => (0.0, 0.0),
266 +
                };
267 +
                p.0 += (tx - p.0) * ease;
268 +
                p.1 += (ty - p.1) * ease;
269 +
            }
270 +
        }
271 +
        let lit_targets: Vec<f32> = self
272 +
            .marks
273 +
            .iter()
274 +
            .map(|m| match self.cursor {
275 +
                Some((x, y)) => {
276 +
                    let (mx, my) = self.position(m);
277 +
                    if (mx - x).hypot(my - y) <= LIGHT_RADIUS {
278 +
                        1.0
279 +
                    } else {
280 +
                        0.0
281 +
                    }
282 +
                }
283 +
                None => 0.0,
284 +
            })
285 +
            .collect();
286 +
        for (m, target) in self.marks.iter_mut().zip(lit_targets) {
287 +
            m.lit = if self.still {
288 +
                target
289 +
            } else if target > m.lit {
290 +
                (m.lit + dt / LIT_IN).min(target)
291 +
            } else {
292 +
                (m.lit - dt / LIT_OUT).max(target)
293 +
            };
294 +
        }
295 +
        let fade_to = if self.cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
296 +
        self.link_fade = if self.still {
297 +
            fade_to
298 +
        } else if fade_to > self.link_fade {
299 +
            (self.link_fade + dt / LIT_IN).min(1.0)
300 +
        } else {
301 +
            (self.link_fade - dt / LIT_OUT).max(0.0)
302 +
        };
303 +
    }
304 +
305 +
    /// Whether anything is still changing: a frame is worth drawing.
306 +
    pub fn busy(&self) -> bool {
307 +
        !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)
308 +
    }
309 +
310 +
    /// One instance per mark: centre x, y and radius in CSS pixels, its
311 +
    /// shape, and its colour premultiplied, 8 floats each.
312 +
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
313 +
        out.clear();
314 +
        for m in &self.marks {
315 +
            let (x, y) = self.position(m);
316 +
            let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright];
317 +
            let col = mix(rest, c.lit, m.lit);
318 +
            let size = m.kind.size() * (1.0 + 0.35 * m.lit);
319 +
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
320 +
        }
321 +
    }
322 +
323 +
    /// The links among lit marks: each repository's lit marks in the order
324 +
    /// they happened, its core to its newest lit mark. Two vertices a
325 +
    /// link, x, y and a premultiplied colour each, 6 floats a vertex.
326 +
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
327 +
        out.clear();
328 +
        if self.link_fade <= 0.0 {
329 +
            return;
330 +
        }
331 +
        for ci in 0..self.clusters.len() {
332 +
            let mut lit: Vec<&Mark> = self.marks.iter().filter(|m| m.cluster == ci && m.lit > 0.05).collect();
333 +
            lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order));
334 +
            for pair in lit.windows(2) {
335 +
                let (a, b) = (self.position(pair[0]), self.position(pair[1]));
336 +
                if (a.0 - b.0).hypot(a.1 - b.1) > LIGHT_RADIUS * 1.2 {
337 +
                    continue;
338 +
                }
339 +
                let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade;
340 +
                let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
341 +
                out.extend_from_slice(&[a.0, a.1, col[0], col[1], col[2], col[3], b.0, b.1, col[0], col[1], col[2], col[3]]);
342 +
            }
343 +
        }
344 +
    }
345 +
346 +
    /// The name to show beside a resting cursor, and where: the nearest
347 +
    /// cluster core within reach.
348 +
    pub fn label(&self) -> Option<(&str, f32, f32)> {
349 +
        let (x, y) = self.cursor?;
350 +
        if self.rested < LABEL_REST {
351 +
            return None;
352 +
        }
353 +
        (0..self.clusters.len())
354 +
            .map(|i| (i, self.core(i)))
355 +
            .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y)))
356 +
            .filter(|t| t.3 <= LABEL_NEAR)
357 +
            .min_by(|a, b| a.3.total_cmp(&b.3))
358 +
            .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy))
359 +
    }
360 +
}
361 +
362 +
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
363 +
    let t = t.clamp(0.0, 1.0);
364 +
    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
365 +
}
366 +
367 +
/// A computed CSS colour: `rgb(r, g, b)`, `rgba(r, g, b, a)` or
368 +
/// `color(srgb r g b / a)`, as browsers report them.
369 +
pub fn parse_colour(s: &str) -> Option<Rgba> {
370 +
    let s = s.trim();
371 +
    if let Some(inner) = s.strip_prefix("color(srgb").and_then(|r| r.strip_suffix(')')) {
372 +
        let (rgb, a) = match inner.split_once('/') {
373 +
            Some((rgb, a)) => (rgb, a.trim().parse().ok()?),
374 +
            None => (inner, 1.0),
375 +
        };
376 +
        let v: Vec<f32> = rgb.split_whitespace().map(|x| x.parse().ok()).collect::<Option<_>>()?;
377 +
        return (v.len() == 3).then(|| [v[0], v[1], v[2], a]);
378 +
    }
379 +
    let inner = s.strip_prefix("rgba(").or_else(|| s.strip_prefix("rgb("))?.strip_suffix(')')?;
380 +
    let v: Vec<f32> = inner.split([',', ' ', '/']).filter(|x| !x.is_empty()).map(|x| x.trim().parse().ok()).collect::<Option<_>>()?;
381 +
    match v.len() {
382 +
        3 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, 1.0]),
383 +
        4 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, v[3]]),
384 +
        _ => None,
385 +
    }
386 +
}
387 +
388 +
#[cfg(test)]
389 +
mod tests {
390 +
    use super::*;
391 +
392 +
    fn sky(still: bool) -> Sky {
393 +
        let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }];
394 +
        let events = vec![
395 +
            Event { repo: 0, kind: Kind::Push, age_days: 0.0 },
396 +
            Event { repo: 0, kind: Kind::Proposal, age_days: 2.0 },
397 +
            Event { repo: 1, kind: Kind::Release, age_days: 29.0 },
398 +
            Event { repo: 1, kind: Kind::Issue, age_days: 45.0 },
399 +
        ];
400 +
        Sky::new(&repos, &events, 1600.0, 900.0, still)
401 +
    }
402 +
403 +
    #[test]
404 +
    fn a_sky_is_the_same_on_every_visit() {
405 +
        let (a, b) = (sky(false), sky(false));
406 +
        assert_eq!(a.clusters[0].fx, b.clusters[0].fx);
407 +
        assert_eq!(a.clusters[1].fy, b.clusters[1].fy);
408 +
        // A core per repository, and one mark per event of the last 30 days.
409 +
        assert_eq!(a.marks.len(), 2 + 3);
410 +
        assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn");
411 +
        let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap();
412 +
        let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap();
413 +
        assert!(newest.bright > old.bright, "newer is brighter");
414 +
        assert_eq!(a.clusters[0].layer, 0, "the busiest in front");
415 +
    }
416 +
417 +
    #[test]
418 +
    fn marks_drift_slowly_and_the_cursor_lights_without_pulling() {
419 +
        let mut s = sky(false);
420 +
        for m in &s.marks {
421 +
            assert!(m.speed.abs() * m.radius <= MAX_SPEED + 0.01, "at most 12 px a second");
422 +
        }
423 +
        let target = s.position(&s.marks[2]);
424 +
        let radii: Vec<f32> = s.marks.iter().map(|m| m.radius).collect();
425 +
        s.point(Some(target));
426 +
        for _ in 0..30 {
427 +
            s.step(1.0 / 60.0);
428 +
        }
429 +
        assert!(s.marks[2].lit > 0.99, "lit near the cursor");
430 +
        assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled");
431 +
        let mut links = Vec::new();
432 +
        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] };
433 +
        s.links(&colours, &mut links);
434 +
        assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined");
435 +
        // Resting fades the links; leaving fades the light.
436 +
        for _ in 0..240 {
437 +
            s.step(1.0 / 60.0);
438 +
        }
439 +
        s.links(&colours, &mut links);
440 +
        assert!(links.is_empty(), "a resting cursor's links fade");
441 +
        s.point(None);
442 +
        for _ in 0..60 {
443 +
            s.step(1.0 / 60.0);
444 +
        }
445 +
        assert!(s.marks.iter().all(|m| m.lit == 0.0));
446 +
    }
447 +
448 +
    #[test]
449 +
    fn still_means_no_drift_and_instant_light() {
450 +
        let mut s = sky(true);
451 +
        let before: Vec<f32> = s.marks.iter().map(|m| m.angle).collect();
452 +
        let at = s.position(&s.marks[2]);
453 +
        s.point(Some(at));
454 +
        s.step(1.0 / 60.0);
455 +
        assert_eq!(before, s.marks.iter().map(|m| m.angle).collect::<Vec<_>>());
456 +
        assert_eq!(s.marks[2].lit, 1.0);
457 +
        assert!(!s.busy(), "nothing left to animate");
458 +
    }
459 +
460 +
    #[test]
461 +
    fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() {
462 +
        let mut s = sky(false);
463 +
        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] };
464 +
        let mut inst = Vec::new();
465 +
        s.instances(&colours, &mut inst);
466 +
        assert_eq!(inst.len(), s.marks.len() * 8);
467 +
        let core = &inst[0..8];
468 +
        assert!((core[4] - core[7]).abs() < 1e-6 && core[7] < 0.5, "premultiplied, dimmed by brightness");
469 +
        let (cx, cy) = s.core(1);
470 +
        s.point(Some((cx + 10.0, cy)));
471 +
        assert!(s.label().is_none(), "not while moving");
472 +
        for _ in 0..10 {
473 +
            s.step(0.05);
474 +
        }
475 +
        assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen"));
476 +
    }
477 +
478 +
    #[test]
479 +
    fn computed_colours_parse() {
480 +
        assert_eq!(parse_colour("rgb(9, 105, 218)"), Some([9.0 / 255.0, 105.0 / 255.0, 218.0 / 255.0, 1.0]));
481 +
        assert_eq!(parse_colour("rgba(0, 240, 255, 0.75)").map(|c| c[3]), Some(0.75));
482 +
        assert_eq!(parse_colour("color(srgb 0 0.5 1 / 0.2)"), Some([0.0, 0.5, 1.0, 0.2]));
483 +
        assert_eq!(parse_colour("rgb(1 2 3 / 0.5)").map(|c| c[3]), Some(0.5));
484 +
        assert!(parse_colour("transparent").is_none());
485 +
    }
486 +
}
modified .oxyignore +1 −0
…
2 unchanged lines
3 3
/data
4 4
*.vsix
5 5
/web/editor/node_modules
6 +
/crates/backdrop/target
modified Makefile +13 −1
1 -
.PHONY: build check test fmt clippy run deploy ts
1 +
.PHONY: build check test fmt clippy run deploy ts backdrop backdrop-check
2 2
3 3
build:
4 4
	cargo build --release
…
27 unchanged lines
32 32
.PHONY: pins
33 33
pins:
34 34
	cd deps/oxygenflow && oxy --plain pull origin && oxy --plain submodule update --init
35 +
36 +
# Backdrops (docs/backgrounds.md): build the engine (crates/backdrop, a
37 +
# workspace of its own) for wasm32 and write its .wasm and wasm-bindgen glue
38 +
# to assets/backdrop/, which the hub embeds and which are committed, so the
39 +
# hub build and its CI need no wasm toolchain. Needs the wasm32-unknown-unknown
40 +
# target and wasm-bindgen-cli at the version in crates/backdrop/Cargo.lock.
41 +
backdrop:
42 +
	cd crates/backdrop && cargo build --release --target wasm32-unknown-unknown
43 +
	wasm-bindgen --target web --no-typescript --out-dir assets/backdrop crates/backdrop/target/wasm32-unknown-unknown/release/backdrop.wasm
44 +
45 +
backdrop-check:
46 +
	cd crates/backdrop && cargo fmt --check && cargo clippy --target wasm32-unknown-unknown -- -D warnings && cargo test
modified assets/github.css +2 −0
…
14 unchanged lines
15 15
  --gh-header:#f6f8fa; --gh-bm-bg:#ddf4ff; --gh-bm-ink:#0969da; --gh-shadow:0 1px 0 rgba(31,35,40,.04);
16 16
  --gh-font:-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
17 17
  --gh-mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
18 +
  --bd-mark:rgba(89,99,110,.16); --bd-lit:rgba(9,105,218,.7); --bd-link:rgba(9,105,218,.22);
18 19
  color-scheme: light;
19 20
}
20 21
:root[data-look="github"][data-theme="dark"] {
…
5 unchanged lines
26 27
  --gh-btn:#212830; --gh-btn-hover:#262c36; --gh-btn-line:#3d444d; --gh-green:#238636; --gh-green-hover:#29903b;
27 28
  --gh-header:#010409; --gh-bm-bg:rgba(56,139,253,.1); --gh-bm-ink:#4493f8; --gh-shadow:none;
28 29
  color-scheme: dark;
30 +
  --bd-mark:rgba(145,152,161,.14); --bd-lit:rgba(0,240,255,.75); --bd-link:rgba(0,240,255,.28);
29 31
}
30 32
31 33
/* Type: the system stack at 14px, no capitals forced on anything. */
…
170 unchanged lines
modified crates/hub/src/web/home.rs +34 −1
…
52 unchanged lines
53 53
    authored: Vec<db::activity::ProposalLine>,
54 54
    assigned: Vec<db::activity::IssueLine>,
55 55
    feed: Vec<db::activity::Activity>,
56 +
    /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script.
57 +
    backdrop: String,
56 58
}
57 59
58 60
/// How many activity lines the dashboard shows.
59 61
const FEED_LIMIT: i64 = 40;
62 +
/// The most events Your sky draws, and how far back (docs/backgrounds.md §5).
63 +
const SKY_EVENTS: i64 = 400;
64 +
const SKY_DAYS: i64 = 30;
60 65
66 +
/// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and
67 +
/// their events of the last 30 days, newest first, as
68 +
/// `{now, repos: [slug], events: [[repo, kind, at_ms]]}` with kinds 0 push,
69 +
/// 1 proposal, 2 issue, 3 release. Only the database is read, never a
70 +
/// repository's store; `<` is escaped so the JSON is safe in a script.
71 +
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::Activity], now: i64) -> String {
72 +
    let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect();
73 +
    let since = now - SKY_DAYS * 86_400_000;
74 +
    let evs: Vec<serde_json::Value> = events
75 +
        .iter()
76 +
        .filter(|e| e.at_ms >= since)
77 +
        .filter_map(|e| {
78 +
            let repo = slugs.iter().position(|s| *s == format!("{}/{}", e.owner, e.name))?;
79 +
            let kind = match e.kind.as_str() {
80 +
                "proposal" => 1,
81 +
                "issue" => 2,
82 +
                "release" => 3,
83 +
                _ => 0,
84 +
            };
85 +
            Some(serde_json::json!([repo, kind, e.at_ms]))
86 +
        })
87 +
        .collect();
88 +
    serde_json::json!({ "now": now, "repos": slugs, "events": evs }).to_string().replace('<', "\\u003c")
89 +
}
90 +
61 91
/// `/`: the dashboard when signed in, every visible repository otherwise.
62 92
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
63 93
    let Some(account) = viewer.account().cloned() else {
…
11 unchanged lines
75 105
    let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
76 106
    let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
77 107
    let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?;
78 -
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed }).into_response())
108 +
    let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
109 +
    let sky = db::activity::feed(&app.db, &mine_ids, SKY_EVENTS).await.map_err(db_err)?;
110 +
    let backdrop = sky_json(&mine, &sky, db::now_ms());
111 +
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response())
79 112
}
80 113
81 114
/// `/explore`: every repository the viewer may see, filtered by text and
…
208 unchanged lines
modified crates/hub/src/web/settings.rs +34 −4
…
45 unchanged lines
46 46
    /// The theme cookie's value, for the Appearance form.
47 47
    theme: &'static str,
48 48
    themes: &'static [(&'static str, &'static str)],
49 +
    /// The backdrop cookie's value and the choices (docs/backgrounds.md §7).
50 +
    backdrop: &'static str,
51 +
    backdrops: &'static [(&'static str, &'static str)],
49 52
    /// Whether the hub sends mail, for the "send a new link" button.
50 53
    mail: bool,
51 54
}
…
1 unchanged lines
53 56
/// The themes (hub spec §6.1): the cookie value and its label.
54 57
const THEMES: &[(&str, &str)] = &[("light", "Light"), ("dark", "Dark"), ("auto", "Match the system")];
55 58
59 +
/// Backdrops (docs/backgrounds.md §7): the cookie value and its label.
60 +
const BACKDROPS: &[(&str, &str)] = &[("moving", "Moving"), ("still", "Still"), ("off", "Off")];
61 +
62 +
/// The backdrop the request's cookie asks for; moving when unset.
63 +
fn backdrop_of(headers: &HeaderMap) -> &'static str {
64 +
    let v = auth::viewer::cookie(headers, "backdrop").unwrap_or_default();
65 +
    BACKDROPS.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("moving")
66 +
}
67 +
56 68
/// The theme the request's cookie asks for; light when unset or unknown.
57 69
fn theme_of(headers: &HeaderMap) -> &'static str {
58 70
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
…
2 unchanged lines
61 73
62 74
impl SettingsPage {
63 75
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
64 -
        SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES, mail: false }
76 +
        SettingsPage {
77 +
            shell,
78 +
            me,
79 +
            notice: None,
80 +
            error: None,
81 +
            theme: theme_of(headers),
82 +
            themes: THEMES,
83 +
            backdrop: backdrop_of(headers),
84 +
            backdrops: BACKDROPS,
85 +
            mail: false,
86 +
        }
65 87
    }
66 88
67 89
    fn with_mail(mut self, app: &Shared) -> Self {
…
64 unchanged lines
132 154
    #[serde(rename = "_csrf")]
133 155
    csrf_token: String,
134 156
    theme: String,
157 +
    #[serde(default)]
158 +
    backdrop: String,
135 159
}
136 160
137 -
/// Remember the theme in a cookie for a year; the page script applies it.
161 +
/// Remember the theme and the backdrop in cookies for a year; the page
162 +
/// scripts apply them.
138 163
pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> {
139 164
    let shell = Shell::new(&app, "/settings", &viewer);
140 165
    require(&viewer, &shell)?;
141 166
    csrf(&viewer, &f.csrf_token, &shell)?;
142 167
    let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else {
143 168
        return Err(Error::bad(shell, "Pick dark, light or match the system."));
169 +
    };
170 +
    let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() };
171 +
    let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else {
172 +
        return Err(Error::bad(shell, "Pick a moving, still or no background."));
144 173
    };
145 174
    Ok((
146 175
        StatusCode::SEE_OTHER,
147 -
        [
176 +
        axum::response::AppendHeaders([
148 177
            (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax")),
178 +
            (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax")),
149 179
            (header::LOCATION, "/settings".to_string()),
150 -
        ],
180 +
        ]),
151 181
    )
152 182
        .into_response())
153 183
}
…
485 unchanged lines
modified crates/hub/src/web/statics.rs +19 −1
…
15 unchanged lines
16 16
    V.get_or_init(|| {
17 17
        let mut h = blake3::Hasher::new();
18 18
        h.update(CSS.as_bytes());
19 -
        for a in ASSETS.iter().filter(|a| matches!(a.path, "github.css" | "hub.js" | "report-frame.js" | "report.css")) {
19 +
        for a in ASSETS.iter().filter(|a| {
20 +
            matches!(
21 +
                a.path,
22 +
                "github.css" | "hub.js" | "report-frame.js" | "report.css" | "backdrop/backdrop.js" | "backdrop/backdrop_bg.wasm"
23 +
            )
24 +
        }) {
20 25
            h.update(a.bytes);
21 26
        }
22 27
        h.finalize().to_hex()[..12].to_string()
…
22 unchanged lines
45 50
    // a report's pages, and the stylesheet a report may link.
46 51
    Asset { path: "report-frame.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/report-frame.js") },
47 52
    Asset { path: "report.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/report.css") },
53 +
    // Backdrops (docs/backgrounds.md, #157): the engine, Rust compiled to
54 +
    // WebAssembly (crates/backdrop), and its wasm-bindgen glue, both
55 +
    // written by `make backdrop`.
48 56
    Asset {
57 +
        path: "backdrop/backdrop.js",
58 +
        mime: "text/javascript; charset=utf-8",
59 +
        bytes: include_bytes!("../../../../assets/backdrop/backdrop.js"),
60 +
    },
61 +
    Asset {
62 +
        path: "backdrop/backdrop_bg.wasm",
63 +
        mime: "application/wasm",
64 +
        bytes: include_bytes!("../../../../assets/backdrop/backdrop_bg.wasm"),
65 +
    },
66 +
    Asset {
49 67
        path: "vendor/editor.js",
50 68
        mime: "text/javascript; charset=utf-8",
51 69
        bytes: include_bytes!("../../../../assets/vendor/editor.js"),
…
47 unchanged lines
modified crates/hub/tests/contrast.rs +37 −0
…
72 unchanged lines
73 73
    let offenders: Vec<&str> = css.lines().filter(|l| l.contains("color: var(--dim)") && !l.contains("border")).collect();
74 74
    assert!(offenders.is_empty(), "--dim is too faint for text:\n{}", offenders.join("\n"));
75 75
}
76 +
77 +
/// `--name:rgba(r,g,b,a)` of the block that starts with `selector`.
78 +
fn rgba(css: &str, selector: &str, name: &str) -> (f64, f64, f64, f64) {
79 +
    let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing"));
80 +
    let body = &css[start..start + css[start..].find('}').unwrap()];
81 +
    let at = body.find(&format!("--{name}:rgba(")).unwrap_or_else(|| panic!("--{name} missing in {selector}"));
82 +
    let inner = &body[at + name.len() + 8..];
83 +
    let v: Vec<f64> = inner[..inner.find(')').unwrap()].split(',').map(|x| x.trim().parse().unwrap()).collect();
84 +
    (v[0], v[1], v[2], v[3])
85 +
}
86 +
87 +
/// Backdrops (docs/backgrounds.md §3): a resting mark is barely there,
88 +
/// under 1.3:1 against the page in every look and theme, and a lit one is
89 +
/// plainly visible.
90 +
#[test]
91 +
fn backdrop_marks_rest_barely_there() {
92 +
    let classic = css();
93 +
    let github = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/github.css")).unwrap();
94 +
    let blocks = [
95 +
        (&classic, ":root {", ":root {"),
96 +
        (&classic, ":root[data-theme=\"light\"] {", ":root[data-theme=\"light\"] {"),
97 +
        (&github, ":root[data-look=\"github\"] {", ":root[data-look=\"github\"] {"),
98 +
        (&github, ":root[data-look=\"github\"][data-theme=\"dark\"] {", ":root[data-look=\"github\"][data-theme=\"dark\"] {"),
99 +
    ];
100 +
    let over = |c: (f64, f64, f64, f64), bg: &str| {
101 +
        let b = |i: usize| u8::from_str_radix(&bg[i..i + 2], 16).unwrap() as f64;
102 +
        let mix = |x: f64, y: f64| (x * c.3 + y * (1.0 - c.3)).round() as u8;
103 +
        format!("#{:02x}{:02x}{:02x}", mix(c.0, b(1)), mix(c.1, b(3)), mix(c.2, b(5)))
104 +
    };
105 +
    for (css, sel, bg_sel) in blocks {
106 +
        let bg = &theme(css, bg_sel)["bg"];
107 +
        let rest = ratio(&over(rgba(css, sel, "bd-mark"), bg), bg);
108 +
        let lit = ratio(&over(rgba(css, sel, "bd-lit"), bg), bg);
109 +
        assert!(rest < 1.3, "{sel}: a resting mark is {rest:.2}:1, too strong");
110 +
        assert!(lit >= 2.0, "{sel}: a lit mark is {lit:.2}:1, too faint");
111 +
    }
112 +
}
modified crates/hub/tests/issues.rs +43 −0
…
372 unchanged lines
373 373
    assert!(page.body.contains("Your repositories"));
374 374
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
375 375
376 +
    // Your sky (#157, docs/backgrounds.md §5): the canvas, and its data from
377 +
    // the database: his repositories and their events, safe in a script.
378 +
    assert!(page.body.contains(r#"<canvas id="backdrop" aria-hidden="true">"#));
379 +
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
380 +
    let data: serde_json::Value = serde_json::from_str(&page.body[start..start + page.body[start..].find("</script>").unwrap()]).unwrap();
381 +
    let repos: Vec<&str> = data["repos"].as_array().unwrap().iter().filter_map(|r| r.as_str()).collect();
382 +
    assert!(repos.contains(&"andy/demo") && repos.contains(&"andy/secret"), "{data}");
383 +
    let events = data["events"].as_array().unwrap();
384 +
    assert!(events.iter().any(|e| e[1] == 1), "the proposals opened are marks: {data}");
385 +
    assert!(events.iter().all(|e| e[0].as_u64().unwrap() < repos.len() as u64));
386 +
    // The engine is served as WebAssembly.
387 +
    let res = oxygenhub::router(app.clone())
388 +
        .oneshot(Request::builder().uri("/static/backdrop/backdrop_bg.wasm").body(Body::empty()).unwrap())
389 +
        .await
390 +
        .unwrap();
391 +
    assert_eq!(res.headers()[header::CONTENT_TYPE], "application/wasm");
392 +
    assert!(res.into_body().collect().await.unwrap().to_bytes().starts_with(b"\0asm"));
393 +
376 394
    // Once he reviews one, it leaves his queue.
377 395
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
378 396
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
…
70 unchanged lines
449 467
    let page = send_with_theme(app, &andy, "dark").await;
450 468
    assert!(page.contains(r#"value="dark" checked"#), "the form shows the cookie's theme");
451 469
    assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST);
470 +
471 +
    // The background (docs/backgrounds.md §7): moving by default, kept in
472 +
    // its own cookie beside the theme.
473 +
    assert!(get(app, "/settings", Some(&andy)).await.body.contains(r#"name="backdrop" value="moving" checked"#));
474 +
    let res = oxygenhub::router(app.clone())
475 +
        .oneshot(
476 +
            Request::builder()
477 +
                .method("POST")
478 +
                .uri("/settings/appearance")
479 +
                .header(header::COOKIE, format!("oxyhub={andy}"))
480 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
481 +
                .body(Body::from(format!("_csrf={csrf}&theme=light&backdrop=still")))
482 +
                .unwrap(),
483 +
        )
484 +
        .await
485 +
        .unwrap();
486 +
    let cookies: Vec<&str> = res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).collect();
487 +
    assert!(
488 +
        cookies.iter().any(|c| c.starts_with("theme=light;")) && cookies.iter().any(|c| c.starts_with("backdrop=still;")),
489 +
        "{cookies:?}"
490 +
    );
491 +
    assert_eq!(
492 +
        post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=light&backdrop=wild")).await.status,
493 +
        StatusCode::BAD_REQUEST
494 +
    );
452 495
    assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST);
453 496
}
454 497
…
455 unchanged lines
modified templates/dashboard.html +23 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Dashboard{% endblock %}
3 3
{% block content %}
4 +
{# Your sky (docs/backgrounds.md §5, #157): the engine is Rust compiled to
5 +
   WebAssembly. The viewer's choice (Settings › Appearance) is a cookie;
6 +
   unset, a touch-only device or Save-Data gets a still sky. Any failure
7 +
   leaves no backdrop and nothing else changed. #}
8 +
<canvas id="backdrop" aria-hidden="true"></canvas>
9 +
<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script>
10 +
<script type="module">
11 +
const canvas = document.getElementById("backdrop");
12 +
const chosen = document.cookie.match(/(?:^|; )backdrop=(moving|still|off)/);
13 +
const touch = matchMedia("(pointer: coarse)").matches && !matchMedia("(pointer: fine)").matches;
14 +
const pick = chosen ? chosen[1] : (touch || (navigator.connection && navigator.connection.saveData) ? "still" : "moving");
15 +
if (pick === "off") {
16 +
  canvas.remove();
17 +
} else {
18 +
  try {
19 +
    const bd = await import("/static/backdrop/backdrop.js?v={{ shell.assets() }}");
20 +
    await bd.default({ module_or_path: "/static/backdrop/backdrop_bg.wasm?v={{ shell.assets() }}" });
21 +
    bd.start(canvas, JSON.parse(document.getElementById("backdrop-data").textContent), pick === "still");
22 +
  } catch (e) {
23 +
    canvas.remove();
24 +
  }
25 +
}
26 +
</script>
4 27
<div class="head">
5 28
  <h1>Dashboard <small>signed in as {% if let Some(u) = shell.user %}{{ u }}{% endif %}</small></h1>
6 29
</div>
…
90 unchanged lines
modified templates/settings.html +6 −0
…
51 unchanged lines
52 52
    <label class="choice"><input type="radio" name="theme" value="{{ value }}"{% if theme == *value %} checked{% endif %}> {{ label }}</label>
53 53
    {% endfor %}
54 54
  </fieldset>
55 +
  <fieldset class="choices">
56 +
    <legend class="small mute">Background, behind the dashboard: drifting marks for your repositories' last 30 days</legend>
57 +
    {% for (value, label) in backdrops %}
58 +
    <label class="choice"><input type="radio" name="backdrop" value="{{ value }}"{% if backdrop == *value %} checked{% endif %}> {{ label }}</label>
59 +
    {% endfor %}
60 +
  </fieldset>
55 61
  <button type="submit">SAVE</button>
56 62
</form>
57 63
<form class="card" id="password" method="post" action="/settings/password">
…
10 unchanged lines
modified templates/style.css +11 −0
…
17 unchanged lines
18 18
  --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad;
19 19
  /* syntax */
20 20
  --sy-kw:#7fb4ff; --sy-str:#8ff0d8; --sy-num:#9fd3ff; --sy-cmt:#7a86a4; --sy-fn:#00e0f0; --sy-ty:#c6a0ff; --sy-var:#e6ecf7; --sy-const:#b48cff;
21 +
  /* Backdrops (docs/backgrounds.md §3): a resting mark, a lit one, a link. */
22 +
  --bd-mark:rgba(0,224,240,.12); --bd-lit:rgba(0,240,255,.85); --bd-link:rgba(0,240,255,.35);
21 23
  color-scheme: dark;
22 24
}
23 25
:root[data-theme="light"] {
…
3 unchanged lines
27 29
  --add:#e1f5ee; --del:#fbe3e8; --add-ink:#176b48; --del-ink:#a1243c;
28 30
  --m1:#0090a8; --m2:#7a8290; --m3:#2f6fcf; --m4:#0055c0; --m5:#8a8f9c;
29 31
  --sy-kw:#0b4fc9; --sy-str:#1a7850; --sy-num:#1e63b8; --sy-cmt:#606b81; --sy-fn:#007394; --sy-ty:#7a3fd1; --sy-var:#1b2333; --sy-const:#6b3fd1;
32 +
  --bd-mark:rgba(95,106,129,.18); --bd-lit:rgba(0,100,224,.7); --bd-link:rgba(0,100,224,.25);
30 33
  color-scheme: light;
31 34
}
32 35
…
1072 unchanged lines
1105 1108
.card-box > .empty { margin: 0; padding: 12px 16px; }
1106 1109
ul.repo-list li { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
1107 1110
ul.repo-list li > a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1111 +
1112 +
/* Backdrops (docs/backgrounds.md, #157): the canvas behind a page's
1113 +
   content, never in its way. The page's background moves to the root so
1114 +
   the canvas shows in the margins and between the cards. */
1115 +
#backdrop { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; display: block; }
1116 +
html:has(#backdrop) body { background: transparent; }
1117 +
.bd-label { position: fixed; z-index: -1; pointer-events: none; font-size: 12px; color: var(--mute); opacity: 0; transition: opacity .3s; white-space: nowrap; }
1118 +
@media (prefers-reduced-motion: reduce) { .bd-label { transition: none; } }