| 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. |
| 35 | export const BANDS = [0, 6 / 11, 9 / 11, 10 / 11, 1]; |
| 36 | |
| 37 | const 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. |
| 43 | export 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. |
| 62 | export 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. |
| 71 | export 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). |
| 82 | export 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. |
| 98 | const 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. |
| 148 | export 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 | |
| 156 | let LIB = QUOTES; // the deck in play — upgraded to the full library below |
| 157 | const LIB_KEY = "lchat_boot_quote_lib"; |
| 158 | let libLoadStarted = false; |
| 159 | function 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 | |
| 183 | const 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). |
| 186 | function 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. |
| 210 | const ROAD_OFF = 0.5; // the strips START in the bottom corners |
| 211 | const ROAD_SIGMA = 0.042; // half-thickness of a strip at its near end |
| 212 | const ROAD_CONV = 1.0; // …and MEET at the far end, behind the avatar |
| 213 | const SCALE_TOP = 0.78; // the far end sits at 22% height — the lockup's spot |
| 214 | const PUNCH = 190; // green → eleven, DeLorean fast |
| 215 | const HOLD = 400; // let the red land — a beat, not a pause |
| 216 | const BOW = 380; // fade one: the scene fades out to the bare cover |
| 217 | const REST = 140; // a breath on the bare cover, so the two fades read as two |
| 218 | const 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). |
| 223 | const STAGE_BRAND = 500; // a bare spinner this long before the brand may enter |
| 224 | const BRAND_FADE = 350; // the brand lockup's ease-in |
| 225 | const BRAND_DWELL = 500; // the faded-in brand's beat before the quote may enter |
| 226 | const QUOTE_READ = 1000; // a fully-faded quote's reading time |
| 227 | const MIN_READ = Q_FADE + QUOTE_READ; // a quote's stay, measured from its deal |
| 228 | const SCENE_FADE = 700; // the smoke and lights' ease-in |
| 229 | |
| 230 | // LED colours per room: neon carries in the dark; deeper inks on paper. |
| 231 | const LEDS_DARK = [[70, 224, 138], [255, 194, 77], [255, 82, 82], [255, 46, 77]]; |
| 232 | const LEDS_LIGHT = [[22, 160, 88], [212, 138, 10], [214, 48, 48], [216, 14, 58]]; |
| 233 | |
| 234 | let scene = null; // the live scene, if startBootScene ran |
| 235 | |
| 236 | // Soft cloud sprite: layered radial blobs, feathered, tinted once per colour. |
| 237 | function 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 | } |
| 259 | function 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 | |
| 270 | function 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. |
| 283 | let brandingDone = false; |
| 284 | function 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. |
| 315 | function 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. |
| 344 | let chrome = null; |
| 345 | function 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 | |
| 368 | export 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. |
| 467 | export 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 | |
| 492 | function 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 | |
| 501 | function 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 | |
| 537 | function 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 | |
| 551 | function 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 | |
| 589 | function 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 | |
| 596 | function 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. |
| 611 | function 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). |
| 697 | function 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 | |
| 710 | function 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 | |
| 742 | function 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 | |
| 791 | function 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 | |
| 809 | poseBootCover(); |
| 810 | |