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 boot loading cover's scene: smoke and rising dust in a
2// dark (or lit) room, lit from below by an UNSEEN 11-segment meter. Two soft
3// strips of light start in the bottom corners and converge to needles behind
4// the server's avatar; while the app actually loads, the level drifts up
5// through the green — zen, no hurry — and the moment boot finishes it slams
6// to eleven (Back-to-the-Future fast), red igniting behind the avatar, holds
7// a beat, and exits on two quick fades — scene to cover, cover to chat. A
8// slow deck of quotes cycles in the lower third.
9//
10// The instrument is never shown. The light is the whole story.
11//
12// The cover ASSEMBLES IN STAGES, so a boot only ever shows what there was
13// time to see (the local store made most boots near-instant, and a quote
14// yanked away mid-fade is worse than no quote): it opens as a bare spinner
15// on the theme bg; still loading at 500ms, the brand (avatar + name) fades
16// in; once that has had its beat, a quote; once THAT has been readable, the
17// smoke and lights. Entering a stage commits the cover to staying up long
18// enough for the stage to be seen (coverWait) — a boot that finishes
19// mid-assembly freezes it where it stands and pays only the dwell already
20// owed, and only a boot that reached the full scene earns the punch to
21// eleven. index.html loads this module DIRECTLY, so the staging clock starts
22// on evaluation (poseBootCover, session pages only), long before the shell's
23// import graph lands; the smoke needs the full shell, so its stage can only
24// open once boot() has called in. shell.js drives the scene through two
25// calls, the same seams the old amp knob had:
26// startBootScene() — begin the climb (boot() path only; embed/no-invite skip)
27// finishBootScene() — freeze or punch; returns how long the cover must hold
28//
29// Pure timing/geometry helpers are exported for the Node tests
30// (test/bootscene.test.mjs) — they must stay DOM-free.
31
32// ---- the meter's bands (fractions of the road's length) -------------------
33// 1–6 green (the loading range), 7–9 amber, 10 red, and the eleventh — one
34// louder — which only ever lights at the punch.
35export const BANDS = [0, 6 / 11, 9 / 11, 10 / 11, 1];
36
37const clamp01 = (v) => Math.min(1, Math.max(0, v));
38
39// Per-colour light at distance-down-the-road u (0 near, 1 at the far end),
40// before any horizontal falloff. Returns [green, amber, red, eleven].
41// The frontier (how far the light has climbed) comes from level+elevenGlow;
42// intensity is hottest near the unseen source and thins with distance.
43export function bandLight(u, level, elevenGlow) {
44 const out = [0, 0, 0, 0];
45 if (u < 0 || u > 1.08) return out;
46 const frontier = (level + elevenGlow) / 11;
47 // soft frontier: the light doesn't end at a hard line in the haze
48 const reach = clamp01((frontier - u) / 0.14 + 1);
49 if (reach <= 0) return out;
50 const I = reach * (1 - u * 0.55) * (1 + 0.9 * Math.exp(-u * 5));
51 const soft = 0.035; // blend across band edges: green breathes into amber
52 for (let b = 0; b < 4; b++) {
53 const w = clamp01((u - BANDS[b]) / soft + 0.5) * clamp01((BANDS[b + 1] - u) / soft + 0.5);
54 if (w <= 0) continue;
55 out[b] += w * I * (b === 3 ? elevenGlow * 1.6 : 1);
56 }
57 return out;
58}
59
60// The quote's fade envelope: 0→1 over fadeMs, hold, back to 0. Age past the
61// full cycle is the caller's cue to deal the next card.
62export function quoteOpacity(age, fadeMs, holdMs) {
63 if (age < 0) return 0;
64 if (age < fadeMs) return age / fadeMs;
65 if (age < fadeMs + holdMs) return 1;
66 return Math.max(0, 1 - (age - fadeMs - holdMs) / fadeMs);
67}
68
69// How long the punch must wait so the quote on stage stays readable: a quote
70// that's been up minReadMs (or none is up — age null) releases immediately.
71export function finishWait(quoteAgeMs, minReadMs) {
72 if (quoteAgeMs == null) return 0;
73 return Math.max(0, minReadMs - quoteAgeMs);
74}
75
76// The dwell the cover still owes at dismissal time, so any stage it ENTERED
77// has been seeable: a brand that entered gets its fade plus a beat; a quote
78// that entered gets its fade plus a read. Ages are ms since the stage
79// entered, null = never entered. A quote only ever enters after the brand's
80// dwell is spent, so its guarantee subsumes the brand's. The smoke's stage
81// adds its own choreography on top (finishBootScene).
82export function coverWait(brandAgeMs, quoteAgeMs) {
83 if (quoteAgeMs != null) return finishWait(quoteAgeMs, MIN_READ);
84 if (brandAgeMs != null) return Math.max(0, BRAND_FADE + BRAND_DWELL - brandAgeMs);
85 return 0;
86}
87
88// ---- the quotes -----------------------------------------------------------
89// Something to actually read while the green drifts. The deck order and a
90// pointer persist in localStorage, so a member never meets the same quote
91// twice until the whole library has played.
92//
93// This embedded deck is only the STARTER — enough for the very first boot and
94// for offline. The full library ships as /static/quotes.json (small on disk,
95// enormous to a human) and is cached in localStorage, so every boot after the
96// first holds the whole deck synchronously: the quote stage can't wait on a
97// fetch.
98const QUOTES = [
99 ["From each according to his ability, to each according to his needs.", "Karl Marx"],
100 ["These go to eleven.", "Nigel Tufnel — This Is Spinal Tap"],
101 ["It's such a fine line between stupid and clever.", "David St. Hubbins — This Is Spinal Tap"],
102 ["Have a good time, all the time.", "Viv Savage — This Is Spinal Tap"],
103 ["How much more black could this be? And the answer is none. None more black.", "Nigel Tufnel — This Is Spinal Tap"],
104 ["Roads? Where we're going, we don't need roads.", "Doc Brown — Back to the Future"],
105 ["Your future is whatever you make it. So make it a good one.", "Doc Brown — Back to the Future Part III"],
106 ["If you put your mind to it, you can accomplish anything.", "Marty McFly — Back to the Future"],
107 ["Be excellent to each other.", "Bill & Ted's Excellent Adventure"],
108 ["Wherever you go, there you are.", "Buckaroo Banzai"],
109 ["Only connect!", "E. M. Forster — Howards End"],
110 ["Mr. Watson — come here — I want to see you.", "Alexander Graham Bell, the first telephone call, 1876"],
111 ["What hath God wrought", "Samuel Morse, the first telegraph message, 1844"],
112 ["LO", "The first message on the ARPANET, 1969 — it crashed before the G of LOGIN"],
113 ["QWERTYUIOP", "Ray Tomlinson, possibly the first email, 1971"],
114 ["The medium is the message.", "Marshall McLuhan"],
115 ["What we've got here is failure to communicate.", "Cool Hand Luke"],
116 ["Help me, Obi-Wan Kenobi. You're my only hope.", "Princess Leia, by hologram — Star Wars"],
117 ["E.T. phone home.", "E.T."],
118 ["Without music, life would be a mistake.", "Friedrich Nietzsche"],
119 ["Music is the space between the notes.", "Claude Debussy"],
120 ["It's not the notes you play, it's the notes you don't play.", "Miles Davis"],
121 ["One good thing about music: when it hits you, you feel no pain.", "Bob Marley"],
122 ["There is a crack in everything. That's how the light gets in.", "Leonard Cohen"],
123 ["Honor thy error as a hidden intention.", "Brian Eno — Oblique Strategies"],
124 ["Repetition is a form of change.", "Brian Eno — Oblique Strategies"],
125 ["Music is a higher revelation than all wisdom and philosophy.", "Ludwig van Beethoven"],
126 ["Where words fail, music speaks.", "Hans Christian Andersen"],
127 ["Music expresses that which cannot be put into words and cannot remain silent.", "Victor Hugo"],
128 ["Writing about music is like dancing about architecture.", "Attributed to practically everyone"],
129 ["If it's too loud, you're too old.", "Rock proverb"],
130 ["The universe is made of stories, not of atoms.", "Muriel Rukeyser"],
131 ["We are made of star-stuff.", "Carl Sagan"],
132 ["Words are events, they do things, change things.", "Ursula K. Le Guin"],
133 ["Listen to many, speak to a few.", "Shakespeare — Hamlet"],
134 ["The most important thing in communication is hearing what isn't said.", "Peter Drucker"],
135 ["The single biggest problem in communication is the illusion that it has taken place.", "Attributed to George Bernard Shaw"],
136 ["The future is already here — it's just not evenly distributed.", "William Gibson"],
137 ["Any sufficiently advanced technology is indistinguishable from magic.", "Arthur C. Clarke"],
138 ["Everything should be made as simple as possible, but no simpler.", "Attributed to Albert Einstein"],
139 ["The real voyage of discovery consists not in seeking new landscapes, but in having new eyes.", "Marcel Proust"],
140 ["No matter how fast light travels, it finds the darkness has always got there first, and is waiting for it.", "Terry Pratchett"],
141 ["I refuse to join any club that would have me as a member.", "Groucho Marx"],
142 ["Time flies like an arrow; fruit flies like a banana.", "Attributed to Groucho Marx"],
143 ["Good night, and good luck.", "Edward R. Murrow"],
144];
145
146// A fetched or cached library is adopted only if it really is one: a
147// non-trivial array of [text, attribution] string pairs.
148export function validQuoteLib(lib) {
149 return (
150 Array.isArray(lib) &&
151 lib.length >= 20 &&
152 lib.every((q) => Array.isArray(q) && typeof q[0] === "string" && q[0].length > 0 && typeof q[1] === "string")
153 );
154}
155
156let LIB = QUOTES; // the deck in play — upgraded to the full library below
157const LIB_KEY = "lchat_boot_quote_lib";
158let libLoadStarted = false;
159function loadQuoteLib() {
160 if (libLoadStarted) return;
161 libLoadStarted = true;
162 try {
163 const cached = JSON.parse(localStorage.getItem(LIB_KEY) || "null");
164 if (validQuoteLib(cached)) LIB = cached;
165 } catch {}
166 // Refresh in the background whenever the shipped file changed. Adopting it
167 // mid-boot just reshuffles the deck on the next deal (nextQuote sees the
168 // length change) — only ever the first boot, before the cache exists.
169 fetch("/static/quotes.json")
170 .then((r) => (r.ok ? r.json() : null))
171 .then((lib) => {
172 if (!validQuoteLib(lib)) return;
173 const s = JSON.stringify(lib);
174 if (s === JSON.stringify(LIB)) return;
175 try {
176 localStorage.setItem(LIB_KEY, s);
177 } catch {}
178 LIB = lib;
179 })
180 .catch(() => {});
181}
182
183const QUOTES_KEY = "lchat_boot_quotes";
184// Deal the next quote: a persisted shuffled order + pointer, reshuffled when
185// the library exhausts (or when it grows — a stale order is just reshuffled).
186function nextQuote() {
187 let state = null;
188 try {
189 state = JSON.parse(localStorage.getItem(QUOTES_KEY) || "null");
190 } catch {}
191 if (!state || !Array.isArray(state.order) || state.order.length !== LIB.length || state.i >= state.order.length) {
192 const order = LIB.map((_, i) => i);
193 for (let i = order.length - 1; i > 0; i--) {
194 const j = (Math.random() * (i + 1)) | 0;
195 [order[i], order[j]] = [order[j], order[i]];
196 }
197 state = { order, i: 0 };
198 }
199 const q = LIB[state.order[state.i]] || LIB[0];
200 state.i++;
201 try {
202 localStorage.setItem(QUOTES_KEY, JSON.stringify(state));
203 } catch {}
204 return q;
205}
206
207// ---- scene geometry & choreography constants ------------------------------
208// All figures were tuned live against the prototype with Paul — treat them as
209// design decisions, not free parameters.
210const ROAD_OFF = 0.5; // the strips START in the bottom corners
211const ROAD_SIGMA = 0.042; // half-thickness of a strip at its near end
212const ROAD_CONV = 1.0; // …and MEET at the far end, behind the avatar
213const SCALE_TOP = 0.78; // the far end sits at 22% height — the lockup's spot
214const PUNCH = 190; // green → eleven, DeLorean fast
215const HOLD = 400; // let the red land — a beat, not a pause
216const BOW = 380; // fade one: the scene fades out to the bare cover
217const REST = 140; // a breath on the bare cover, so the two fades read as two
218const Q_FADE = 2200,
219 Q_HOLD = 7600; // one quote every ~12s — genuinely slow
220// The staged assembly's clock: each stage opens only if the app is STILL
221// loading once the previous one has had its look, and each entered stage
222// commits the cover to a minimum stay (coverWait above).
223const STAGE_BRAND = 500; // a bare spinner this long before the brand may enter
224const BRAND_FADE = 350; // the brand lockup's ease-in
225const BRAND_DWELL = 500; // the faded-in brand's beat before the quote may enter
226const QUOTE_READ = 1000; // a fully-faded quote's reading time
227const MIN_READ = Q_FADE + QUOTE_READ; // a quote's stay, measured from its deal
228const SCENE_FADE = 700; // the smoke and lights' ease-in
229
230// LED colours per room: neon carries in the dark; deeper inks on paper.
231const LEDS_DARK = [[70, 224, 138], [255, 194, 77], [255, 82, 82], [255, 46, 77]];
232const LEDS_LIGHT = [[22, 160, 88], [212, 138, 10], [214, 48, 48], [216, 14, 58]];
233
234let scene = null; // the live scene, if startBootScene ran
235
236// Soft cloud sprite: layered radial blobs, feathered, tinted once per colour.
237function makePuffShape(size) {
238 const c = document.createElement("canvas");
239 c.width = c.height = size;
240 const p = c.getContext("2d");
241 for (let i = 0; i < 24; i++) {
242 const bx = size / 2 + (Math.random() - 0.5) * size * 0.52;
243 const by = size / 2 + (Math.random() - 0.5) * size * 0.38;
244 const br = size * (0.09 + Math.random() * 0.17);
245 const grad = p.createRadialGradient(bx, by, 0, bx, by, br);
246 grad.addColorStop(0, "rgba(255,255,255,0.055)");
247 grad.addColorStop(1, "rgba(255,255,255,0)");
248 p.fillStyle = grad;
249 p.fillRect(0, 0, size, size);
250 }
251 const mask = p.createRadialGradient(size / 2, size / 2, size * 0.3, size / 2, size / 2, size * 0.5);
252 mask.addColorStop(0, "rgba(0,0,0,1)");
253 mask.addColorStop(1, "rgba(0,0,0,0)");
254 p.globalCompositeOperation = "destination-in";
255 p.fillStyle = mask;
256 p.fillRect(0, 0, size, size);
257 return c;
258}
259function tinted(shape, rgb) {
260 const c = document.createElement("canvas");
261 c.width = c.height = shape.width;
262 const p = c.getContext("2d");
263 p.drawImage(shape, 0, 0);
264 p.globalCompositeOperation = "source-in";
265 p.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
266 p.fillRect(0, 0, c.width, c.height);
267 return c;
268}
269
270function coverEls() {
271 return {
272 brand: document.getElementById("boot-brand"),
273 spin: document.getElementById("boot-spin"),
274 quote: document.getElementById("boot-quote"),
275 qText: document.getElementById("boot-quote-text"),
276 qAttr: document.getElementById("boot-quote-attr"),
277 footer: document.getElementById("boot-goesto"),
278 };
279}
280
281// The letter tile + server photo (plaintext branding — renders before any
282// keys exist). Once per page, whichever stage gets there first.
283let brandingDone = false;
284function poseBranding(cover) {
285 if (brandingDone) return;
286 brandingDone = true;
287 const nameEl = cover.querySelector(".boot-name");
288 const img = document.getElementById("boot-avatar-img");
289 const letter = cover.querySelector(".boot-avatar-letter");
290 if (letter && nameEl) letter.textContent = [...(nameEl.textContent || "").trim()][0] || "";
291 fetch("/api/group")
292 .then((r) => r.json())
293 .then((gr) => {
294 if (!gr || !img) return;
295 // Photo if the admin set one; the Eleven mark if the instance is fully
296 // unbranded (no photo AND no chosen name); the letter tile otherwise.
297 if (gr.avatar) img.alt = gr.avatar_alt || "";
298 else if (!gr.name_set) img.alt = "";
299 else return;
300 img.onload = () => {
301 img.hidden = false;
302 };
303 img.src = gr.avatar ? "/api/server-avatar" : "/static/icon-192.png";
304 })
305 .catch(() => {});
306}
307
308// Advance the staged assembly and pose the cover's DOM chrome — one routine
309// for both tickers (the chrome-only pre-shell one and the full scene's), over
310// the same fields, so the scene adopts the chrome's stage clocks seamlessly.
311// Stages only OPEN during the climb; any other phase means the exit is under
312// way and the assembly freezes where it stands (mid-fades still complete —
313// coverWait keeps the cover up long enough for that). Reduced motion gets the
314// same stages as snaps instead of fades.
315function stepCover(S, now) {
316 if (S.phase === "climb") {
317 if (S.brandAt == null && now - S.t0 >= STAGE_BRAND) S.brandAt = now;
318 // the smoke's stage needs the full scene (S.canvas), and opens only once
319 // the quote on stage has had its read
320 if (S.canvas && S.sceneAt == null && S.qStart != null && now - S.qStart >= MIN_READ) S.sceneAt = now;
321 }
322 stepQuotes(S, now);
323 const els = S.els;
324 const out = 1 - (S.fade || 0); // the bow: everything leaves together
325 const rise = S.brandAt == null ? 0 : S.reduced ? 1 : Math.min(1, (now - S.brandAt) / BRAND_FADE);
326 if (els.brand) els.brand.style.opacity = (rise * out).toFixed(3);
327 if (els.footer) els.footer.style.opacity = (0.8 * rise * out).toFixed(3);
328 // the spinner leaves at the punch (work is done — the light says the rest)
329 if (els.spin) els.spin.style.opacity = S.phase === "punch" || S.phase === "hold" || S.phase === "bow" ? "0" : "0.85";
330 if (els.quote) {
331 const q = S.qStart == null ? 0 : S.reduced ? 0.9 : quoteOpacity(now - S.qStart, Q_FADE, Q_HOLD);
332 els.quote.style.opacity = (q * out).toFixed(3);
333 }
334 if (S.canvas) {
335 const litIn = S.sceneAt == null ? 0 : S.reduced ? 1 : Math.min(1, (now - S.sceneAt) / SCENE_FADE);
336 S.canvas.style.opacity = (litIn * out).toFixed(3);
337 }
338}
339
340// Stage one: the bare cover, posed the instant this module lands — long
341// before the shell's import graph has arrived — and the staging clock starts.
342// Only on pages that will actually boot: the native embed and signed-out
343// visitors never see the cover chrome.
344let chrome = null;
345function poseBootCover() {
346 if (typeof document === "undefined") return; // Node (tests) imports this module
347 const cover = document.getElementById("app-loading");
348 const canvas = document.getElementById("boot-canvas");
349 if (!cover || !canvas) return; // not the app page
350 if (window.__lchatEmbed) return;
351 let session = null;
352 try {
353 session = localStorage.getItem("lchat_session");
354 } catch {}
355 if (!session) return;
356 loadQuoteLib(); // the full deck, from cache now and the server in the background
357 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
358 chrome = { t0: performance.now(), brandAt: null, qStart: null, quote: null, phase: "climb", els: coverEls(), reduced, fade: 0, raf: 0 };
359 poseBranding(cover);
360 const tick = (now) => {
361 if (scene || !canvas.isConnected) return; // the scene took over / cover gone
362 stepCover(chrome, now);
363 chrome.raf = requestAnimationFrame(tick);
364 };
365 chrome.raf = requestAnimationFrame(tick);
366}
367
368export function startBootScene() {
369 const cover = document.getElementById("app-loading");
370 const canvas = document.getElementById("boot-canvas");
371 if (!cover || !canvas) return;
372 const g = canvas.getContext("2d");
373 if (!g) return;
374 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
375
376 // Which room are we in? Judge by the RESOLVED page background (the user's
377 // theme + colour-scheme decide it, not the OS media query alone) — same
378 // trick the old dust used. Dark room: white smoke, additive light. Lit
379 // room: dark grey smoke that occludes, deeper LED inks.
380 const bgRGB = ((getComputedStyle(document.body).backgroundColor || "").match(/\d+/g) || []).map(Number);
381 const dark = bgRGB.length >= 3 && 0.299 * bgRGB[0] + 0.587 * bgRGB[1] + 0.114 * bgRGB[2] < 128;
382 const room = dark
383 ? { comp: "lighter", neutral: [216, 216, 224], neutralA: 0.26, leds: LEDS_DARK, glowA: 0.38, spillA: 0.3, mote: [255, 255, 255], moteDark: false }
384 : { comp: "source-over", neutral: [74, 74, 80], neutralA: 0.34, leds: LEDS_LIGHT, glowA: 0.34, spillA: 0.26, mote: [12, 12, 16], moteDark: true };
385
386 poseBranding(cover); // no-op when the chrome stage already did it
387 loadQuoteLib(); // ditto — poseBootCover normally beat us to it
388
389 const shapes = Array.from({ length: 6 }, () => makePuffShape(256));
390 const sprites = shapes.map((s) => ({ neutral: tinted(s, room.neutral), lit: room.leds.map((c) => tinted(s, c)) }));
391
392 const S = {
393 cover,
394 canvas,
395 g,
396 room,
397 sprites,
398 reduced,
399 W: 0,
400 H: 0,
401 puffs: [],
402 motes: [],
403 level: 0,
404 elevenGlow: 0,
405 fade: 0,
406 // Adopt the chrome stage's clocks wholesale: the staged assembly carries
407 // on mid-fade, the climb reflects how long the member has ACTUALLY been
408 // waiting (so the light arrives already part-way up the green), and the
409 // quote on stage carries over mid-cycle.
410 phase: chrome ? chrome.phase : "climb",
411 t0: chrome ? chrome.t0 : performance.now(),
412 brandAt: chrome ? chrome.brandAt : null,
413 qStart: chrome ? chrome.qStart : null,
414 quote: chrome ? chrome.quote : null,
415 exitAt: chrome ? chrome.exitAt : undefined,
416 sceneAt: null, // the smoke's stage — stepCover opens it once the quote's been read
417 readyT: 0,
418 levelAtReady: 0,
419 raf: 0,
420 glowCv: null,
421 grain: null,
422 els: coverEls(),
423 };
424 scene = S; // set before cancelling the chrome ticker — its guard checks this
425 if (chrome) {
426 cancelAnimationFrame(chrome.raf);
427 chrome = null;
428 }
429 sizeScene(S);
430 S.onResize = () => {
431 sizeScene(S);
432 seedScene(S);
433 if (S.reduced) drawFrame(S, 0, performance.now()); // re-pose the still
434 };
435 addEventListener("resize", S.onResize);
436 seedScene(S);
437 makeGrain(S);
438
439 if (reduced) {
440 // A single considered still — mid-green, everything at rest — drawn once,
441 // ready behind the canvas' staged opacity; the ticker below only snaps
442 // the stages in (no motion).
443 S.level = 5;
444 drawFrame(S, 0, performance.now());
445 }
446 let last = performance.now();
447 const tick = (now) => {
448 if (!S.canvas.isConnected) return; // hideLoading removed the cover
449 const dt = Math.min(0.05, (now - last) / 1000);
450 last = now;
451 if (!S.reduced) stepChoreo(S, now);
452 stepCover(S, now);
453 // nothing to draw while the smoke's stage hasn't opened (canvas is clear)
454 if (!S.reduced && S.sceneAt != null) drawFrame(S, dt, now);
455 S.raf = requestAnimationFrame(tick);
456 };
457 S.raf = requestAnimationFrame(tick);
458}
459
460// The cover's dismissal. Freezes the staged assembly and returns how long
461// hideLoading must hold the cover: the dwell owed to whatever stages made it
462// on (coverWait) — and, only when the smoke's stage opened, the full finale:
463// the quote on stage keeps its read, the smoke its own fade-in, then the
464// punch to eleven. That exit is two quick fades: the scene fades to the bare
465// cover (the bow), then the cover itself fades into the chat (.scene-out).
466// Idempotent: repeat calls report the remaining hold, never a shorter one.
467export function finishBootScene() {
468 const S = scene || chrome;
469 if (!S) return 0; // boot paths that never posed the cover just fade
470 const now = performance.now();
471 if (S.phase !== "climb") return Math.max(0, Math.round((S.exitAt || 0) - now));
472 const brandAge = S.brandAt == null ? null : now - S.brandAt;
473 const quoteAge = S.qStart == null ? null : now - S.qStart;
474 if (S.sceneAt == null || S.reduced) {
475 // The smoke never made the stage (or reduced motion — no punch theatre):
476 // exit on the plain cover fade after the dwell owed, plus a beat when a
477 // reduced still is up.
478 S.phase = "done";
479 const wait = Math.round(coverWait(brandAge, quoteAge)) + (S.sceneAt == null ? 0 : 300);
480 S.exitAt = now + wait;
481 return wait;
482 }
483 const wait = Math.max(finishWait(quoteAge, MIN_READ), S.sceneAt + SCENE_FADE - now, 0);
484 S.punchAt = now + wait;
485 S.phase = "armed";
486 S.cover.classList.add("scene-out");
487 const total = Math.round(wait + PUNCH + HOLD + BOW + REST);
488 S.exitAt = now + total;
489 return total;
490}
491
492function sizeScene(S) {
493 const dpr = Math.min(2, window.devicePixelRatio || 1);
494 S.W = window.innerWidth;
495 S.H = window.innerHeight;
496 S.canvas.width = S.W * dpr;
497 S.canvas.height = S.H * dpr;
498 S.g.setTransform(dpr, 0, 0, dpr, 0, 0);
499}
500
501function seedScene(S) {
502 const { W, H } = S;
503 S.puffs = [];
504 // Two counter-drifting layers hanging in the lower two thirds — the smoke
505 // floats above the meter, it doesn't fill the ceiling. Counts scale down on
506 // small screens so phones aren't pushing a desktop's worth of sprites.
507 const k = Math.max(0.5, Math.min(1, W / 1100));
508 for (const layer of [
509 { n: Math.round(22 * k), s: [480, 860], v: [13, 26], a: 0.6, dir: 1, band: [0.3, 0.98] },
510 { n: Math.round(18 * k), s: [240, 480], v: [22, 42], a: 0.8, dir: -1, band: [0.42, 1.0] },
511 ]) {
512 for (let i = 0; i < layer.n; i++) {
513 S.puffs.push({
514 x: Math.random() * (W + 400) - 200,
515 y: H * (layer.band[0] + Math.random() * (layer.band[1] - layer.band[0])),
516 s: layer.s[0] + Math.random() * (layer.s[1] - layer.s[0]),
517 vx: layer.dir * (layer.v[0] + Math.random() * (layer.v[1] - layer.v[0])),
518 rot: Math.random() * Math.PI * 2,
519 vr: (Math.random() - 0.5) * 0.13,
520 a: layer.a * (0.6 + Math.random() * 0.4),
521 spi: (Math.random() * S.sprites.length) | 0,
522 wob: Math.random() * Math.PI * 2,
523 });
524 }
525 }
526 S.motes = Array.from({ length: Math.min(130, Math.round(W / 11)) }, () => ({
527 x: Math.random() * W,
528 y: Math.random() * H,
529 r: 0.5 + Math.random() * 1.2,
530 vy: -(8 + Math.random() * 18), // motes rise on the warm air
531 sway: 6 + Math.random() * 14,
532 ph: Math.random() * Math.PI * 2,
533 tw: 0.5 + Math.random() * 1.6,
534 }));
535}
536
537function makeGrain(S) {
538 const c = document.createElement("canvas");
539 c.width = c.height = 256;
540 const p = c.getContext("2d");
541 const img = p.createImageData(256, 256);
542 for (let i = 0; i < img.data.length; i += 4) {
543 const v = 118 + Math.random() * 96;
544 img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
545 img.data[i + 3] = 255;
546 }
547 p.putImageData(img, 0, 0);
548 S.grain = c;
549}
550
551function stepChoreo(S, now) {
552 const t = now - S.t0;
553 if (S.phase === "climb") {
554 // the amp warming up — zen; it can never leave the green on its own
555 S.level = 6.2 * (1 - Math.exp(-t / 5000));
556 } else if (S.phase === "armed") {
557 // finishBootScene ran; honouring the quote's read + the smoke's fade-in
558 S.level = 6.2 * (1 - Math.exp(-t / 5000));
559 if (now >= S.punchAt) {
560 S.readyT = now;
561 S.levelAtReady = S.level;
562 S.phase = "punch";
563 }
564 } else if (S.phase === "punch") {
565 // 88 miles per hour: an accelerating slam, green to eleven in one gesture
566 const k = Math.min(1, (now - S.readyT) / PUNCH);
567 const L = S.levelAtReady + (11 - S.levelAtReady) * Math.pow(k, 1.8);
568 S.level = Math.min(10, L);
569 S.elevenGlow = Math.max(0, L - 10);
570 if (k >= 1) {
571 S.readyT = now;
572 S.phase = "hold";
573 }
574 } else if (S.phase === "hold") {
575 S.level = 10;
576 S.elevenGlow = 1;
577 if (now - S.readyT >= HOLD) {
578 S.readyT = now;
579 S.phase = "bow";
580 }
581 } else if (S.phase === "bow") {
582 // first fade: the scene melts to the bare cover, still at eleven
583 S.level = 10;
584 S.elevenGlow = 1;
585 S.fade = Math.min(1, (now - S.readyT) / BOW);
586 }
587}
588
589function dealQuote(S, now) {
590 S.quote = nextQuote();
591 S.qStart = now;
592 if (S.els.qText) S.els.qText.textContent = S.quote[0];
593 if (S.els.qAttr) S.els.qAttr.textContent = S.quote[1];
594}
595
596function stepQuotes(S, now) {
597 if (S.phase !== "climb") return; // exit under way — the card on stage is the last
598 if (S.qStart == null) {
599 // the quote's stage opens once the brand has had its fade and its beat —
600 // a boot that finishes sooner never shows one
601 if (S.brandAt != null && now - S.brandAt >= BRAND_FADE + BRAND_DWELL) dealQuote(S, now);
602 return;
603 }
604 // reduced motion holds its one considered card; otherwise the deck cycles
605 if (!S.reduced && now - S.qStart >= Q_FADE * 2 + Q_HOLD) dealQuote(S, now);
606}
607
608// The under-glow: both road strips drawn as nested soft trapezoids into a
609// LOW-RES offscreen (plus a blur), upscaled onto the cover — the upscale is
610// what keeps the light diffuse and seam-free.
611function drawGlow(S) {
612 const { g, W, H, room } = S;
613 const scale = H * SCALE_TOP;
614 if ((S.level + S.elevenGlow) / 11 <= 0.005) return;
615 const K = 14;
616 const gw = Math.max(2, Math.round(W / K)),
617 gh = Math.max(2, Math.round(H / K));
618 if (!S.glowCv) S.glowCv = document.createElement("canvas");
619 if (S.glowCv.width !== gw || S.glowCv.height !== gh) {
620 S.glowCv.width = gw;
621 S.glowCv.height = gh;
622 }
623 const p = S.glowCv.getContext("2d");
624 p.setTransform(gw / W, 0, 0, gh / H, 0, 0);
625 p.clearRect(0, 0, W, H);
626 p.globalCompositeOperation = "lighter";
627 p.filter = "blur(3px)"; // ~40px of softness once upscaled; harmless where unsupported
628 const stripCentre = (side, u) => W / 2 + side * W * ROAD_OFF * (1 - ROAD_CONV * clamp01(u));
629 const stripSigma = (u) => W * ROAD_SIGMA * (1 - 0.95 * clamp01(u)); // needles at the far end
630 // the shared colour ramp along the road: green near, amber, red, eleven far
631 const grad = p.createLinearGradient(0, H, 0, H - scale);
632 for (let i = 0; i <= 26; i++) {
633 const u = i / 26;
634 const l = bandLight(u, S.level, S.elevenGlow);
635 let r = 0,
636 gr = 0,
637 b = 0,
638 sum = 0;
639 for (let c = 0; c < 4; c++) {
640 r += room.leds[c][0] * l[c];
641 gr += room.leds[c][1] * l[c];
642 b += room.leds[c][2] * l[c];
643 sum += l[c];
644 }
645 if (sum > 0.001) {
646 r /= sum;
647 gr /= sum;
648 b /= sum;
649 }
650 grad.addColorStop(u, `rgba(${r | 0},${gr | 0},${b | 0},${(Math.min(1, sum) * room.glowA).toFixed(3)})`);
651 }
652 p.fillStyle = grad;
653 for (const side of [-1, 1]) {
654 const cxb = stripCentre(side, 0),
655 cxt = stripCentre(side, 1);
656 const wb = stripSigma(0) * 1.7,
657 wt = stripSigma(1) * 1.7;
658 for (const [m, a] of [[3.6, 0.11], [2.1, 0.22], [1.0, 0.28]]) {
659 p.globalAlpha = a;
660 p.beginPath();
661 p.moveTo(cxb - wb * m, H + 4);
662 p.lineTo(cxb + wb * m, H + 4);
663 p.lineTo(cxt + wt * m, H - scale);
664 p.lineTo(cxt - wt * m, H - scale);
665 p.closePath();
666 p.fill();
667 }
668 }
669 p.globalAlpha = 1;
670 // hot pools where the strips enter the frame, in the near corners
671 const gl = room.leds[0];
672 const sI = Math.min(1, (S.level + S.elevenGlow) / 1.5) * (1 + S.elevenGlow * 0.5);
673 for (const side of [-1, 1]) {
674 const cx = stripCentre(side, 0);
675 const pool = p.createRadialGradient(cx, H + 4, 0, cx, H + 4, W * 0.1);
676 pool.addColorStop(0, `rgba(${gl[0]},${gl[1]},${gl[2]},${(room.spillA * sI).toFixed(3)})`);
677 pool.addColorStop(1, `rgba(${gl[0]},${gl[1]},${gl[2]},0)`);
678 p.fillStyle = pool;
679 p.save();
680 p.translate(cx, H + 4);
681 p.scale(1, 0.32);
682 p.beginPath();
683 p.arc(0, 0, W * 0.1, 0, 7);
684 p.fill();
685 p.restore();
686 }
687 p.filter = "none";
688 g.save();
689 g.globalCompositeOperation = room.comp;
690 g.imageSmoothingEnabled = true;
691 g.drawImage(S.glowCv, 0, 0, W, H);
692 g.restore();
693}
694
695// Per-colour light at a point: the vertical band climb times the two strips'
696// sideways falloff (they narrow and converge with distance).
697function lightAt(S, x, y) {
698 const u = (S.H - y) / (S.H * SCALE_TOP);
699 const out = bandLight(u, S.level, S.elevenGlow);
700 const s = S.W * ROAD_SIGMA * (1 - 0.95 * clamp01(u));
701 const cOff = S.W * ROAD_OFF * (1 - ROAD_CONV * clamp01(u));
702 const d1 = (x - (S.W / 2 - cOff)) / s;
703 const d2 = (x - (S.W / 2 + cOff)) / s;
704 const f = Math.min(1, Math.exp(-d1 * d1) + Math.exp(-d2 * d2));
705 if (f < 0.01) return [0, 0, 0, 0];
706 for (let c = 0; c < 4; c++) out[c] *= f;
707 return out;
708}
709
710function drawSmoke(S, dt) {
711 const { g, W } = S;
712 g.save();
713 g.globalCompositeOperation = S.room.comp;
714 for (const p of S.puffs) {
715 if (!S.reduced) {
716 p.x += p.vx * dt;
717 p.rot += p.vr * dt;
718 p.wob += dt * 0.55;
719 if (p.x < -p.s) p.x = W + p.s * 0.6;
720 else if (p.x > W + p.s) p.x = -p.s * 0.6;
721 }
722 const y = p.y + Math.sin(p.wob) * 20;
723 // sample a little BELOW the puff's centre — lit-from-beneath undersides
724 const light = lightAt(S, p.x, y + p.s * 0.22);
725 const sp = S.sprites[p.spi];
726 g.save();
727 g.translate(p.x, y);
728 g.rotate(p.rot);
729 g.globalAlpha = p.a * S.room.neutralA; // its own presence, even unlit
730 g.drawImage(sp.neutral, -p.s / 2, -p.s / 2, p.s, p.s);
731 for (let c = 0; c < 4; c++) {
732 const a = p.a * light[c] * (c === 3 ? 0.9 : 0.55);
733 if (a < 0.005) continue;
734 g.globalAlpha = Math.min(0.65, a);
735 g.drawImage(sp.lit[c], -p.s / 2, -p.s / 2, p.s, p.s);
736 }
737 g.restore();
738 }
739 g.restore();
740}
741
742function drawMotes(S, dt, now) {
743 const { g, W, H, room } = S;
744 g.save();
745 for (const m of S.motes) {
746 if (!S.reduced) {
747 m.y += m.vy * dt;
748 m.ph += dt * 0.9;
749 if (m.y < -4) {
750 m.y = H + 4;
751 m.x = Math.random() * W;
752 }
753 }
754 const x = m.x + Math.sin(m.ph) * m.sway;
755 const light = lightAt(S, x, m.y);
756 const tw = 0.65 + 0.35 * Math.sin(now * 0.001 * m.tw + m.ph * 3);
757 let sum = 0,
758 r = room.mote[0],
759 gr = room.mote[1],
760 b = room.mote[2];
761 for (let c = 0; c < 4; c++) sum += light[c];
762 let a;
763 if (room.moteDark) {
764 // lit room: dark dust, always faintly there, warmed by nearby colour
765 a = (0.36 + Math.min(0.3, sum * 0.5)) * tw;
766 for (let c = 0; c < 4; c++) {
767 r += (room.leds[c][0] - r) * light[c] * 0.25;
768 gr += (room.leds[c][1] - gr) * light[c] * 0.25;
769 b += (room.leds[c][2] - b) * light[c] * 0.25;
770 }
771 } else {
772 // dark room: a faint ambient glint, catching fire crossing a strip
773 g.globalCompositeOperation = "lighter";
774 a = Math.min(0.95, 0.12 + sum * 1.1) * tw;
775 for (let c = 0; c < 4; c++) {
776 r += (room.leds[c][0] - r) * Math.min(1, light[c] * 1.4);
777 gr += (room.leds[c][1] - gr) * Math.min(1, light[c] * 1.4);
778 b += (room.leds[c][2] - b) * Math.min(1, light[c] * 1.4);
779 }
780 }
781 if (a < 0.015) continue;
782 g.globalAlpha = a;
783 g.fillStyle = `rgb(${Math.min(255, r | 0)},${Math.min(255, gr | 0)},${Math.min(255, b | 0)})`;
784 g.beginPath();
785 g.arc(x, m.y, m.r, 0, 7);
786 g.fill();
787 }
788 g.restore();
789}
790
791function drawFrame(S, dt, now) {
792 const { g, W, H } = S;
793 g.clearRect(0, 0, W, H); // transparent: the cover's own --bg shows through
794 drawGlow(S);
795 drawSmoke(S, dt);
796 drawMotes(S, dt, now);
797 if (S.grain) {
798 g.save();
799 g.globalAlpha = 0.045;
800 g.globalCompositeOperation = "overlay";
801 const ox = -((Math.random() * 256) | 0),
802 oy = -((Math.random() * 256) | 0);
803 for (let x = ox; x < W; x += 256) for (let y = oy; y < H; y += 256) g.drawImage(S.grain, x, y);
804 g.restore();
805 }
806 // (the DOM chrome and the canvas' own staged opacity are stepCover's job)
807}
808
809poseBootCover();
810