eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// bootscene.js — the home loading cover's scene: soft smoke and rising dust
2// motes drifting in a theme-matched haze. A trimmed sibling of the messenger's
3// boot scene (web/static/bootscene.js): the SAME smoke + motes, but none of the
4// "lights" (no under-lit 11-segment meter), no quotes, and no punch-to-eleven.
5//
6// Home just needs a calm cover over the gap between "signed in on this browser"
7// and "your spaces list has decrypted + rendered", so a refresh doesn't flash
8// the Sign-in screen first. It's shown only for returning members (a live
9// session in sessionStorage) — a pre-paint gate in index.html adds html.booting
10// so the cover is up from the very first frame; signed-out visitors never see it.
11//
12// home.js drives it with two calls:
13// startHomeLoader() — reveal the cover + begin the drift
14// finishHomeLoader() — fade the cover away once the list (or Sign-in) is ready
15
16// Hold the loader at least this long before it fades — a deliberate beat on the
17// scene, so even an instant load gets ~2s of smoke rather than a strobe.
18const MIN_SHOW = 2000;
19// On the session paths the cover's pageantry holds this long before appearing
20// at all (the cover-wait CSS in index.html — the cover still blocks from frame
21// zero, only its visibility waits). A load that lifts inside the window never
22// showed chrome, so it skips the dwell and the exit fade too.
23const CHROME_DELAY = 500;
24
25let scene = null; // the live scene, if startHomeLoader ran
26
27// Soft cloud sprite: layered radial blobs, feathered, tinted once. (Identical to
28// the messenger scene's puff so the smoke reads the same.)
29function makePuffShape(size) {
30 const c = document.createElement("canvas");
31 c.width = c.height = size;
32 const p = c.getContext("2d");
33 for (let i = 0; i < 24; i++) {
34 const bx = size / 2 + (Math.random() - 0.5) * size * 0.52;
35 const by = size / 2 + (Math.random() - 0.5) * size * 0.38;
36 const br = size * (0.09 + Math.random() * 0.17);
37 const grad = p.createRadialGradient(bx, by, 0, bx, by, br);
38 grad.addColorStop(0, "rgba(255,255,255,0.055)");
39 grad.addColorStop(1, "rgba(255,255,255,0)");
40 p.fillStyle = grad;
41 p.fillRect(0, 0, size, size);
42 }
43 const mask = p.createRadialGradient(size / 2, size / 2, size * 0.3, size / 2, size / 2, size * 0.5);
44 mask.addColorStop(0, "rgba(0,0,0,1)");
45 mask.addColorStop(1, "rgba(0,0,0,0)");
46 p.globalCompositeOperation = "destination-in";
47 p.fillStyle = mask;
48 p.fillRect(0, 0, size, size);
49 return c;
50}
51function tinted(shape, rgb) {
52 const c = document.createElement("canvas");
53 c.width = c.height = shape.width;
54 const p = c.getContext("2d");
55 p.drawImage(shape, 0, 0);
56 p.globalCompositeOperation = "source-in";
57 p.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
58 p.fillRect(0, 0, c.width, c.height);
59 return c;
60}
61
62export function startHomeLoader() {
63 const canvas = document.getElementById("boot-canvas");
64 if (!canvas) return;
65 const g = canvas.getContext("2d");
66 if (!g) return;
67 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
68
69 // Which room are we in? Judge by the RESOLVED page background (theme +
70 // colour-scheme decide it). Dark room: white smoke, additive glints. Lit
71 // room: dark grey smoke + dust that occludes over the light bg.
72 const bgRGB = ((getComputedStyle(document.body).backgroundColor || "").match(/\d+/g) || []).map(Number);
73 const dark = bgRGB.length >= 3 && 0.299 * bgRGB[0] + 0.587 * bgRGB[1] + 0.114 * bgRGB[2] < 128;
74 const room = dark
75 ? { comp: "lighter", neutral: [216, 216, 224], neutralA: 0.26, mote: [255, 255, 255], moteA: 0.42, puffMul: 1 }
76 : { comp: "source-over", neutral: [26, 28, 38], neutralA: 0.65, mote: [24, 26, 36], moteA: 0.65, puffMul: 1.7 };
77
78 const shapes = Array.from({ length: 6 }, () => makePuffShape(256));
79 const sprites = shapes.map((s) => tinted(s, room.neutral));
80
81 const S = {
82 canvas, g, room, sprites, reduced,
83 W: 0, H: 0, puffs: [], motes: [], grain: null, raf: 0, t0: performance.now(),
84 };
85 scene = S;
86 sizeScene(S);
87 seedScene(S);
88 makeGrain(S);
89 S.onResize = () => { sizeScene(S); seedScene(S); };
90 addEventListener("resize", S.onResize);
91
92 if (reduced) { drawFrame(S, 0, performance.now()); return; } // a single considered still
93 let last = performance.now();
94 const tick = (now) => {
95 if (!S.canvas.isConnected || scene !== S) return; // finishHomeLoader removed/replaced it
96 const dt = Math.min(0.05, (now - last) / 1000);
97 last = now;
98 drawFrame(S, dt, now);
99 S.raf = requestAnimationFrame(tick);
100 };
101 S.raf = requestAnimationFrame(tick);
102}
103
104// finishHomeLoader lifts the loading chrome: it drops html.booting so the
105// content fades in, and fades out the cover's chrome — leaving the smoke canvas
106// running as the page's ambient background. A minimum on-screen time keeps an
107// instant load from strobing; { immediate: true } skips the floor for callers
108// that ran their own choreography (the Apple return's staged exit in home.js).
109// The chrome is parked, not removed — the Apple tap re-engages it later (the
110// html.siwa-wait waiting face), so it must survive its first fade-out.
111export function finishHomeLoader({ immediate = false } = {}) {
112 const chrome = document.getElementById("app-loading");
113 const elapsed = scene ? performance.now() - scene.t0 : MIN_SHOW;
114 // The fast path: a session boot lifting while the page is younger than the
115 // chrome delay. The pageantry never appeared (its CSS clock starts at first
116 // paint — later than performance.now()'s navigation-start epoch, so this
117 // check errs on the safe side of "never seen"), so nothing needs a graceful
118 // exit: reveal the content at once, html.boot-fast trimming its fade to an
119 // imperceptible beat. The siwa journey face keeps its own choreography.
120 const fast = !immediate && performance.now() < CHROME_DELAY &&
121 !document.documentElement.classList.contains("siwa-wait");
122 setTimeout(() => {
123 if (fast) document.documentElement.classList.add("boot-fast");
124 document.documentElement.classList.remove("booting"); // reveal + fade the content in
125 if (chrome) {
126 chrome.classList.add("cover-out"); // fade the chrome out (it keeps display for the ride)
127 setTimeout(() => {
128 chrome.classList.remove("cover-out", "brand-in"); // parked (display:none), ready to re-engage
129 document.documentElement.classList.remove("siwa-wait", "boot-fast"); // the wait face dies with its chrome
130 }, fast ? 80 : 600);
131 }
132 // NB: the smoke canvas + its rAF are intentionally left running — the scene
133 // is the page's living backdrop now, not a cover to tear down.
134 }, immediate || fast ? 0 : Math.max(0, MIN_SHOW - elapsed));
135}
136
137function sizeScene(S) {
138 const dpr = Math.min(2, window.devicePixelRatio || 1);
139 S.W = window.innerWidth;
140 S.H = window.innerHeight;
141 S.canvas.width = S.W * dpr;
142 S.canvas.height = S.H * dpr;
143 S.g.setTransform(dpr, 0, 0, dpr, 0, 0);
144}
145
146function seedScene(S) {
147 const { W, H } = S;
148 // Two counter-drifting layers hanging in the lower two thirds — the smoke
149 // floats, it doesn't fill the ceiling. Counts scale down on small screens.
150 const k = Math.max(0.5, Math.min(1, W / 1100));
151 const mul = S.room.puffMul || 1; // per-room smoke density (light needs more)
152 S.puffs = [];
153 for (const layer of [
154 { n: Math.round(22 * k * mul), s: [480, 860], v: [13, 26], a: 0.6, dir: 1, band: [0.3, 0.98] },
155 { n: Math.round(18 * k * mul), s: [240, 480], v: [22, 42], a: 0.8, dir: -1, band: [0.42, 1.0] },
156 ]) {
157 for (let i = 0; i < layer.n; i++) {
158 S.puffs.push({
159 x: Math.random() * (W + 400) - 200,
160 y: H * (layer.band[0] + Math.random() * (layer.band[1] - layer.band[0])),
161 s: layer.s[0] + Math.random() * (layer.s[1] - layer.s[0]),
162 vx: layer.dir * (layer.v[0] + Math.random() * (layer.v[1] - layer.v[0])),
163 rot: Math.random() * Math.PI * 2,
164 vr: (Math.random() - 0.5) * 0.13,
165 a: layer.a * (0.6 + Math.random() * 0.4),
166 spi: (Math.random() * S.sprites.length) | 0,
167 wob: Math.random() * Math.PI * 2,
168 });
169 }
170 }
171 S.motes = Array.from({ length: Math.min(130, Math.round(W / 11)) }, () => ({
172 x: Math.random() * W,
173 y: Math.random() * H,
174 r: 0.5 + Math.random() * 1.2,
175 vy: -(8 + Math.random() * 18), // motes rise on the warm air
176 sway: 6 + Math.random() * 14,
177 ph: Math.random() * Math.PI * 2,
178 tw: 0.5 + Math.random() * 1.6,
179 }));
180}
181
182function makeGrain(S) {
183 const c = document.createElement("canvas");
184 c.width = c.height = 256;
185 const p = c.getContext("2d");
186 const img = p.createImageData(256, 256);
187 for (let i = 0; i < img.data.length; i += 4) {
188 const v = 118 + Math.random() * 96;
189 img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
190 img.data[i + 3] = 255;
191 }
192 p.putImageData(img, 0, 0);
193 S.grain = c;
194}
195
196function drawSmoke(S, dt) {
197 const { g, W } = S;
198 g.save();
199 g.globalCompositeOperation = S.room.comp;
200 g.globalAlpha = 1;
201 for (const p of S.puffs) {
202 if (!S.reduced) {
203 p.x += p.vx * dt;
204 p.rot += p.vr * dt;
205 p.wob += dt * 0.55;
206 if (p.x < -p.s) p.x = W + p.s * 0.6;
207 else if (p.x > W + p.s) p.x = -p.s * 0.6;
208 }
209 const y = p.y + Math.sin(p.wob) * 20;
210 g.save();
211 g.translate(p.x, y);
212 g.rotate(p.rot);
213 g.globalAlpha = p.a * S.room.neutralA; // no meter to light it — its own quiet presence
214 g.drawImage(S.sprites[p.spi], -p.s / 2, -p.s / 2, p.s, p.s);
215 g.restore();
216 }
217 g.restore();
218}
219
220function drawMotes(S, dt, now) {
221 const { g, W, H, room } = S;
222 g.save();
223 g.globalCompositeOperation = room.comp; // dark room: additive glints; lit room: over
224 for (const m of S.motes) {
225 if (!S.reduced) {
226 m.y += m.vy * dt;
227 m.ph += dt * 0.9;
228 if (m.y < -4) {
229 m.y = H + 4;
230 m.x = Math.random() * W;
231 }
232 }
233 const x = m.x + Math.sin(m.ph) * m.sway;
234 const tw = 0.65 + 0.35 * Math.sin(now * 0.001 * m.tw + m.ph * 3);
235 const a = room.moteA * tw;
236 if (a < 0.015) continue;
237 g.globalAlpha = a;
238 g.fillStyle = `rgb(${room.mote[0]},${room.mote[1]},${room.mote[2]})`;
239 g.beginPath();
240 g.arc(x, m.y, m.r, 0, 7);
241 g.fill();
242 }
243 g.restore();
244}
245
246function drawFrame(S, dt, now) {
247 const { g, W, H } = S;
248 g.clearRect(0, 0, W, H); // transparent: the cover's own --bg shows through
249 drawSmoke(S, dt);
250 drawMotes(S, dt, now);
251 if (S.grain) {
252 g.save();
253 g.globalAlpha = 0.045;
254 g.globalCompositeOperation = "overlay";
255 const ox = -((Math.random() * 256) | 0),
256 oy = -((Math.random() * 256) | 0);
257 for (let x = ox; x < W; x += 256) for (let y = oy; y < H; y += 256) g.drawImage(S.grain, x, y);
258 g.restore();
259 }
260}
261