// bootscene.js — the boot loading cover's scene: smoke and rising dust in a // dark (or lit) room, lit from below by an UNSEEN 11-segment meter. Two soft // strips of light start in the bottom corners and converge to needles behind // the server's avatar; while the app actually loads, the level drifts up // through the green — zen, no hurry — and the moment boot finishes it slams // to eleven (Back-to-the-Future fast), red igniting behind the avatar, holds // a beat, and exits on two quick fades — scene to cover, cover to chat. A // slow deck of quotes cycles in the lower third. // // The instrument is never shown. The light is the whole story. // // The cover ASSEMBLES IN STAGES, so a boot only ever shows what there was // time to see (the local store made most boots near-instant, and a quote // yanked away mid-fade is worse than no quote): it opens as a bare spinner // on the theme bg; still loading at 500ms, the brand (avatar + name) fades // in; once that has had its beat, a quote; once THAT has been readable, the // smoke and lights. Entering a stage commits the cover to staying up long // enough for the stage to be seen (coverWait) — a boot that finishes // mid-assembly freezes it where it stands and pays only the dwell already // owed, and only a boot that reached the full scene earns the punch to // eleven. index.html loads this module DIRECTLY, so the staging clock starts // on evaluation (poseBootCover, session pages only), long before the shell's // import graph lands; the smoke needs the full shell, so its stage can only // open once boot() has called in. shell.js drives the scene through two // calls, the same seams the old amp knob had: // startBootScene() — begin the climb (boot() path only; embed/no-invite skip) // finishBootScene() — freeze or punch; returns how long the cover must hold // // Pure timing/geometry helpers are exported for the Node tests // (test/bootscene.test.mjs) — they must stay DOM-free. // ---- the meter's bands (fractions of the road's length) ------------------- // 1–6 green (the loading range), 7–9 amber, 10 red, and the eleventh — one // louder — which only ever lights at the punch. export const BANDS = [0, 6 / 11, 9 / 11, 10 / 11, 1]; const clamp01 = (v) => Math.min(1, Math.max(0, v)); // Per-colour light at distance-down-the-road u (0 near, 1 at the far end), // before any horizontal falloff. Returns [green, amber, red, eleven]. // The frontier (how far the light has climbed) comes from level+elevenGlow; // intensity is hottest near the unseen source and thins with distance. export function bandLight(u, level, elevenGlow) { const out = [0, 0, 0, 0]; if (u < 0 || u > 1.08) return out; const frontier = (level + elevenGlow) / 11; // soft frontier: the light doesn't end at a hard line in the haze const reach = clamp01((frontier - u) / 0.14 + 1); if (reach <= 0) return out; const I = reach * (1 - u * 0.55) * (1 + 0.9 * Math.exp(-u * 5)); const soft = 0.035; // blend across band edges: green breathes into amber for (let b = 0; b < 4; b++) { const w = clamp01((u - BANDS[b]) / soft + 0.5) * clamp01((BANDS[b + 1] - u) / soft + 0.5); if (w <= 0) continue; out[b] += w * I * (b === 3 ? elevenGlow * 1.6 : 1); } return out; } // The quote's fade envelope: 0→1 over fadeMs, hold, back to 0. Age past the // full cycle is the caller's cue to deal the next card. export function quoteOpacity(age, fadeMs, holdMs) { if (age < 0) return 0; if (age < fadeMs) return age / fadeMs; if (age < fadeMs + holdMs) return 1; return Math.max(0, 1 - (age - fadeMs - holdMs) / fadeMs); } // How long the punch must wait so the quote on stage stays readable: a quote // that's been up minReadMs (or none is up — age null) releases immediately. export function finishWait(quoteAgeMs, minReadMs) { if (quoteAgeMs == null) return 0; return Math.max(0, minReadMs - quoteAgeMs); } // The dwell the cover still owes at dismissal time, so any stage it ENTERED // has been seeable: a brand that entered gets its fade plus a beat; a quote // that entered gets its fade plus a read. Ages are ms since the stage // entered, null = never entered. A quote only ever enters after the brand's // dwell is spent, so its guarantee subsumes the brand's. The smoke's stage // adds its own choreography on top (finishBootScene). export function coverWait(brandAgeMs, quoteAgeMs) { if (quoteAgeMs != null) return finishWait(quoteAgeMs, MIN_READ); if (brandAgeMs != null) return Math.max(0, BRAND_FADE + BRAND_DWELL - brandAgeMs); return 0; } // ---- the quotes ----------------------------------------------------------- // Something to actually read while the green drifts. The deck order and a // pointer persist in localStorage, so a member never meets the same quote // twice until the whole library has played. // // This embedded deck is only the STARTER — enough for the very first boot and // for offline. The full library ships as /static/quotes.json (small on disk, // enormous to a human) and is cached in localStorage, so every boot after the // first holds the whole deck synchronously: the quote stage can't wait on a // fetch. const QUOTES = [ ["From each according to his ability, to each according to his needs.", "Karl Marx"], ["These go to eleven.", "Nigel Tufnel — This Is Spinal Tap"], ["It's such a fine line between stupid and clever.", "David St. Hubbins — This Is Spinal Tap"], ["Have a good time, all the time.", "Viv Savage — This Is Spinal Tap"], ["How much more black could this be? And the answer is none. None more black.", "Nigel Tufnel — This Is Spinal Tap"], ["Roads? Where we're going, we don't need roads.", "Doc Brown — Back to the Future"], ["Your future is whatever you make it. So make it a good one.", "Doc Brown — Back to the Future Part III"], ["If you put your mind to it, you can accomplish anything.", "Marty McFly — Back to the Future"], ["Be excellent to each other.", "Bill & Ted's Excellent Adventure"], ["Wherever you go, there you are.", "Buckaroo Banzai"], ["Only connect!", "E. M. Forster — Howards End"], ["Mr. Watson — come here — I want to see you.", "Alexander Graham Bell, the first telephone call, 1876"], ["What hath God wrought", "Samuel Morse, the first telegraph message, 1844"], ["LO", "The first message on the ARPANET, 1969 — it crashed before the G of LOGIN"], ["QWERTYUIOP", "Ray Tomlinson, possibly the first email, 1971"], ["The medium is the message.", "Marshall McLuhan"], ["What we've got here is failure to communicate.", "Cool Hand Luke"], ["Help me, Obi-Wan Kenobi. You're my only hope.", "Princess Leia, by hologram — Star Wars"], ["E.T. phone home.", "E.T."], ["Without music, life would be a mistake.", "Friedrich Nietzsche"], ["Music is the space between the notes.", "Claude Debussy"], ["It's not the notes you play, it's the notes you don't play.", "Miles Davis"], ["One good thing about music: when it hits you, you feel no pain.", "Bob Marley"], ["There is a crack in everything. That's how the light gets in.", "Leonard Cohen"], ["Honor thy error as a hidden intention.", "Brian Eno — Oblique Strategies"], ["Repetition is a form of change.", "Brian Eno — Oblique Strategies"], ["Music is a higher revelation than all wisdom and philosophy.", "Ludwig van Beethoven"], ["Where words fail, music speaks.", "Hans Christian Andersen"], ["Music expresses that which cannot be put into words and cannot remain silent.", "Victor Hugo"], ["Writing about music is like dancing about architecture.", "Attributed to practically everyone"], ["If it's too loud, you're too old.", "Rock proverb"], ["The universe is made of stories, not of atoms.", "Muriel Rukeyser"], ["We are made of star-stuff.", "Carl Sagan"], ["Words are events, they do things, change things.", "Ursula K. Le Guin"], ["Listen to many, speak to a few.", "Shakespeare — Hamlet"], ["The most important thing in communication is hearing what isn't said.", "Peter Drucker"], ["The single biggest problem in communication is the illusion that it has taken place.", "Attributed to George Bernard Shaw"], ["The future is already here — it's just not evenly distributed.", "William Gibson"], ["Any sufficiently advanced technology is indistinguishable from magic.", "Arthur C. Clarke"], ["Everything should be made as simple as possible, but no simpler.", "Attributed to Albert Einstein"], ["The real voyage of discovery consists not in seeking new landscapes, but in having new eyes.", "Marcel Proust"], ["No matter how fast light travels, it finds the darkness has always got there first, and is waiting for it.", "Terry Pratchett"], ["I refuse to join any club that would have me as a member.", "Groucho Marx"], ["Time flies like an arrow; fruit flies like a banana.", "Attributed to Groucho Marx"], ["Good night, and good luck.", "Edward R. Murrow"], ]; // A fetched or cached library is adopted only if it really is one: a // non-trivial array of [text, attribution] string pairs. export function validQuoteLib(lib) { return ( Array.isArray(lib) && lib.length >= 20 && lib.every((q) => Array.isArray(q) && typeof q[0] === "string" && q[0].length > 0 && typeof q[1] === "string") ); } let LIB = QUOTES; // the deck in play — upgraded to the full library below const LIB_KEY = "lchat_boot_quote_lib"; let libLoadStarted = false; function loadQuoteLib() { if (libLoadStarted) return; libLoadStarted = true; try { const cached = JSON.parse(localStorage.getItem(LIB_KEY) || "null"); if (validQuoteLib(cached)) LIB = cached; } catch {} // Refresh in the background whenever the shipped file changed. Adopting it // mid-boot just reshuffles the deck on the next deal (nextQuote sees the // length change) — only ever the first boot, before the cache exists. fetch("/static/quotes.json") .then((r) => (r.ok ? r.json() : null)) .then((lib) => { if (!validQuoteLib(lib)) return; const s = JSON.stringify(lib); if (s === JSON.stringify(LIB)) return; try { localStorage.setItem(LIB_KEY, s); } catch {} LIB = lib; }) .catch(() => {}); } const QUOTES_KEY = "lchat_boot_quotes"; // Deal the next quote: a persisted shuffled order + pointer, reshuffled when // the library exhausts (or when it grows — a stale order is just reshuffled). function nextQuote() { let state = null; try { state = JSON.parse(localStorage.getItem(QUOTES_KEY) || "null"); } catch {} if (!state || !Array.isArray(state.order) || state.order.length !== LIB.length || state.i >= state.order.length) { const order = LIB.map((_, i) => i); for (let i = order.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [order[i], order[j]] = [order[j], order[i]]; } state = { order, i: 0 }; } const q = LIB[state.order[state.i]] || LIB[0]; state.i++; try { localStorage.setItem(QUOTES_KEY, JSON.stringify(state)); } catch {} return q; } // ---- scene geometry & choreography constants ------------------------------ // All figures were tuned live against the prototype with Paul — treat them as // design decisions, not free parameters. const ROAD_OFF = 0.5; // the strips START in the bottom corners const ROAD_SIGMA = 0.042; // half-thickness of a strip at its near end const ROAD_CONV = 1.0; // …and MEET at the far end, behind the avatar const SCALE_TOP = 0.78; // the far end sits at 22% height — the lockup's spot const PUNCH = 190; // green → eleven, DeLorean fast const HOLD = 400; // let the red land — a beat, not a pause const BOW = 380; // fade one: the scene fades out to the bare cover const REST = 140; // a breath on the bare cover, so the two fades read as two const Q_FADE = 2200, Q_HOLD = 7600; // one quote every ~12s — genuinely slow // The staged assembly's clock: each stage opens only if the app is STILL // loading once the previous one has had its look, and each entered stage // commits the cover to a minimum stay (coverWait above). const STAGE_BRAND = 500; // a bare spinner this long before the brand may enter const BRAND_FADE = 350; // the brand lockup's ease-in const BRAND_DWELL = 500; // the faded-in brand's beat before the quote may enter const QUOTE_READ = 1000; // a fully-faded quote's reading time const MIN_READ = Q_FADE + QUOTE_READ; // a quote's stay, measured from its deal const SCENE_FADE = 700; // the smoke and lights' ease-in // LED colours per room: neon carries in the dark; deeper inks on paper. const LEDS_DARK = [[70, 224, 138], [255, 194, 77], [255, 82, 82], [255, 46, 77]]; const LEDS_LIGHT = [[22, 160, 88], [212, 138, 10], [214, 48, 48], [216, 14, 58]]; let scene = null; // the live scene, if startBootScene ran // Soft cloud sprite: layered radial blobs, feathered, tinted once per colour. function makePuffShape(size) { const c = document.createElement("canvas"); c.width = c.height = size; const p = c.getContext("2d"); for (let i = 0; i < 24; i++) { const bx = size / 2 + (Math.random() - 0.5) * size * 0.52; const by = size / 2 + (Math.random() - 0.5) * size * 0.38; const br = size * (0.09 + Math.random() * 0.17); const grad = p.createRadialGradient(bx, by, 0, bx, by, br); grad.addColorStop(0, "rgba(255,255,255,0.055)"); grad.addColorStop(1, "rgba(255,255,255,0)"); p.fillStyle = grad; p.fillRect(0, 0, size, size); } const mask = p.createRadialGradient(size / 2, size / 2, size * 0.3, size / 2, size / 2, size * 0.5); mask.addColorStop(0, "rgba(0,0,0,1)"); mask.addColorStop(1, "rgba(0,0,0,0)"); p.globalCompositeOperation = "destination-in"; p.fillStyle = mask; p.fillRect(0, 0, size, size); return c; } function tinted(shape, rgb) { const c = document.createElement("canvas"); c.width = c.height = shape.width; const p = c.getContext("2d"); p.drawImage(shape, 0, 0); p.globalCompositeOperation = "source-in"; p.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; p.fillRect(0, 0, c.width, c.height); return c; } function coverEls() { return { brand: document.getElementById("boot-brand"), spin: document.getElementById("boot-spin"), quote: document.getElementById("boot-quote"), qText: document.getElementById("boot-quote-text"), qAttr: document.getElementById("boot-quote-attr"), footer: document.getElementById("boot-goesto"), }; } // The letter tile + server photo (plaintext branding — renders before any // keys exist). Once per page, whichever stage gets there first. let brandingDone = false; function poseBranding(cover) { if (brandingDone) return; brandingDone = true; const nameEl = cover.querySelector(".boot-name"); const img = document.getElementById("boot-avatar-img"); const letter = cover.querySelector(".boot-avatar-letter"); if (letter && nameEl) letter.textContent = [...(nameEl.textContent || "").trim()][0] || ""; fetch("/api/group") .then((r) => r.json()) .then((gr) => { if (!gr || !img) return; // Photo if the admin set one; the Eleven mark if the instance is fully // unbranded (no photo AND no chosen name); the letter tile otherwise. if (gr.avatar) img.alt = gr.avatar_alt || ""; else if (!gr.name_set) img.alt = ""; else return; img.onload = () => { img.hidden = false; }; img.src = gr.avatar ? "/api/server-avatar" : "/static/icon-192.png"; }) .catch(() => {}); } // Advance the staged assembly and pose the cover's DOM chrome — one routine // for both tickers (the chrome-only pre-shell one and the full scene's), over // the same fields, so the scene adopts the chrome's stage clocks seamlessly. // Stages only OPEN during the climb; any other phase means the exit is under // way and the assembly freezes where it stands (mid-fades still complete — // coverWait keeps the cover up long enough for that). Reduced motion gets the // same stages as snaps instead of fades. function stepCover(S, now) { if (S.phase === "climb") { if (S.brandAt == null && now - S.t0 >= STAGE_BRAND) S.brandAt = now; // the smoke's stage needs the full scene (S.canvas), and opens only once // the quote on stage has had its read if (S.canvas && S.sceneAt == null && S.qStart != null && now - S.qStart >= MIN_READ) S.sceneAt = now; } stepQuotes(S, now); const els = S.els; const out = 1 - (S.fade || 0); // the bow: everything leaves together const rise = S.brandAt == null ? 0 : S.reduced ? 1 : Math.min(1, (now - S.brandAt) / BRAND_FADE); if (els.brand) els.brand.style.opacity = (rise * out).toFixed(3); if (els.footer) els.footer.style.opacity = (0.8 * rise * out).toFixed(3); // the spinner leaves at the punch (work is done — the light says the rest) if (els.spin) els.spin.style.opacity = S.phase === "punch" || S.phase === "hold" || S.phase === "bow" ? "0" : "0.85"; if (els.quote) { const q = S.qStart == null ? 0 : S.reduced ? 0.9 : quoteOpacity(now - S.qStart, Q_FADE, Q_HOLD); els.quote.style.opacity = (q * out).toFixed(3); } if (S.canvas) { const litIn = S.sceneAt == null ? 0 : S.reduced ? 1 : Math.min(1, (now - S.sceneAt) / SCENE_FADE); S.canvas.style.opacity = (litIn * out).toFixed(3); } } // Stage one: the bare cover, posed the instant this module lands — long // before the shell's import graph has arrived — and the staging clock starts. // Only on pages that will actually boot: the native embed and signed-out // visitors never see the cover chrome. let chrome = null; function poseBootCover() { if (typeof document === "undefined") return; // Node (tests) imports this module const cover = document.getElementById("app-loading"); const canvas = document.getElementById("boot-canvas"); if (!cover || !canvas) return; // not the app page if (window.__lchatEmbed) return; let session = null; try { session = localStorage.getItem("lchat_session"); } catch {} if (!session) return; loadQuoteLib(); // the full deck, from cache now and the server in the background const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; chrome = { t0: performance.now(), brandAt: null, qStart: null, quote: null, phase: "climb", els: coverEls(), reduced, fade: 0, raf: 0 }; poseBranding(cover); const tick = (now) => { if (scene || !canvas.isConnected) return; // the scene took over / cover gone stepCover(chrome, now); chrome.raf = requestAnimationFrame(tick); }; chrome.raf = requestAnimationFrame(tick); } export function startBootScene() { const cover = document.getElementById("app-loading"); const canvas = document.getElementById("boot-canvas"); if (!cover || !canvas) return; const g = canvas.getContext("2d"); if (!g) return; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; // Which room are we in? Judge by the RESOLVED page background (the user's // theme + colour-scheme decide it, not the OS media query alone) — same // trick the old dust used. Dark room: white smoke, additive light. Lit // room: dark grey smoke that occludes, deeper LED inks. const bgRGB = ((getComputedStyle(document.body).backgroundColor || "").match(/\d+/g) || []).map(Number); const dark = bgRGB.length >= 3 && 0.299 * bgRGB[0] + 0.587 * bgRGB[1] + 0.114 * bgRGB[2] < 128; const room = dark ? { comp: "lighter", neutral: [216, 216, 224], neutralA: 0.26, leds: LEDS_DARK, glowA: 0.38, spillA: 0.3, mote: [255, 255, 255], moteDark: false } : { 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 }; poseBranding(cover); // no-op when the chrome stage already did it loadQuoteLib(); // ditto — poseBootCover normally beat us to it const shapes = Array.from({ length: 6 }, () => makePuffShape(256)); const sprites = shapes.map((s) => ({ neutral: tinted(s, room.neutral), lit: room.leds.map((c) => tinted(s, c)) })); const S = { cover, canvas, g, room, sprites, reduced, W: 0, H: 0, puffs: [], motes: [], level: 0, elevenGlow: 0, fade: 0, // Adopt the chrome stage's clocks wholesale: the staged assembly carries // on mid-fade, the climb reflects how long the member has ACTUALLY been // waiting (so the light arrives already part-way up the green), and the // quote on stage carries over mid-cycle. phase: chrome ? chrome.phase : "climb", t0: chrome ? chrome.t0 : performance.now(), brandAt: chrome ? chrome.brandAt : null, qStart: chrome ? chrome.qStart : null, quote: chrome ? chrome.quote : null, exitAt: chrome ? chrome.exitAt : undefined, sceneAt: null, // the smoke's stage — stepCover opens it once the quote's been read readyT: 0, levelAtReady: 0, raf: 0, glowCv: null, grain: null, els: coverEls(), }; scene = S; // set before cancelling the chrome ticker — its guard checks this if (chrome) { cancelAnimationFrame(chrome.raf); chrome = null; } sizeScene(S); S.onResize = () => { sizeScene(S); seedScene(S); if (S.reduced) drawFrame(S, 0, performance.now()); // re-pose the still }; addEventListener("resize", S.onResize); seedScene(S); makeGrain(S); if (reduced) { // A single considered still — mid-green, everything at rest — drawn once, // ready behind the canvas' staged opacity; the ticker below only snaps // the stages in (no motion). S.level = 5; drawFrame(S, 0, performance.now()); } let last = performance.now(); const tick = (now) => { if (!S.canvas.isConnected) return; // hideLoading removed the cover const dt = Math.min(0.05, (now - last) / 1000); last = now; if (!S.reduced) stepChoreo(S, now); stepCover(S, now); // nothing to draw while the smoke's stage hasn't opened (canvas is clear) if (!S.reduced && S.sceneAt != null) drawFrame(S, dt, now); S.raf = requestAnimationFrame(tick); }; S.raf = requestAnimationFrame(tick); } // The cover's dismissal. Freezes the staged assembly and returns how long // hideLoading must hold the cover: the dwell owed to whatever stages made it // on (coverWait) — and, only when the smoke's stage opened, the full finale: // the quote on stage keeps its read, the smoke its own fade-in, then the // punch to eleven. That exit is two quick fades: the scene fades to the bare // cover (the bow), then the cover itself fades into the chat (.scene-out). // Idempotent: repeat calls report the remaining hold, never a shorter one. export function finishBootScene() { const S = scene || chrome; if (!S) return 0; // boot paths that never posed the cover just fade const now = performance.now(); if (S.phase !== "climb") return Math.max(0, Math.round((S.exitAt || 0) - now)); const brandAge = S.brandAt == null ? null : now - S.brandAt; const quoteAge = S.qStart == null ? null : now - S.qStart; if (S.sceneAt == null || S.reduced) { // The smoke never made the stage (or reduced motion — no punch theatre): // exit on the plain cover fade after the dwell owed, plus a beat when a // reduced still is up. S.phase = "done"; const wait = Math.round(coverWait(brandAge, quoteAge)) + (S.sceneAt == null ? 0 : 300); S.exitAt = now + wait; return wait; } const wait = Math.max(finishWait(quoteAge, MIN_READ), S.sceneAt + SCENE_FADE - now, 0); S.punchAt = now + wait; S.phase = "armed"; S.cover.classList.add("scene-out"); const total = Math.round(wait + PUNCH + HOLD + BOW + REST); S.exitAt = now + total; return total; } function sizeScene(S) { const dpr = Math.min(2, window.devicePixelRatio || 1); S.W = window.innerWidth; S.H = window.innerHeight; S.canvas.width = S.W * dpr; S.canvas.height = S.H * dpr; S.g.setTransform(dpr, 0, 0, dpr, 0, 0); } function seedScene(S) { const { W, H } = S; S.puffs = []; // Two counter-drifting layers hanging in the lower two thirds — the smoke // floats above the meter, it doesn't fill the ceiling. Counts scale down on // small screens so phones aren't pushing a desktop's worth of sprites. const k = Math.max(0.5, Math.min(1, W / 1100)); for (const layer of [ { n: Math.round(22 * k), s: [480, 860], v: [13, 26], a: 0.6, dir: 1, band: [0.3, 0.98] }, { n: Math.round(18 * k), s: [240, 480], v: [22, 42], a: 0.8, dir: -1, band: [0.42, 1.0] }, ]) { for (let i = 0; i < layer.n; i++) { S.puffs.push({ x: Math.random() * (W + 400) - 200, y: H * (layer.band[0] + Math.random() * (layer.band[1] - layer.band[0])), s: layer.s[0] + Math.random() * (layer.s[1] - layer.s[0]), vx: layer.dir * (layer.v[0] + Math.random() * (layer.v[1] - layer.v[0])), rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 0.13, a: layer.a * (0.6 + Math.random() * 0.4), spi: (Math.random() * S.sprites.length) | 0, wob: Math.random() * Math.PI * 2, }); } } S.motes = Array.from({ length: Math.min(130, Math.round(W / 11)) }, () => ({ x: Math.random() * W, y: Math.random() * H, r: 0.5 + Math.random() * 1.2, vy: -(8 + Math.random() * 18), // motes rise on the warm air sway: 6 + Math.random() * 14, ph: Math.random() * Math.PI * 2, tw: 0.5 + Math.random() * 1.6, })); } function makeGrain(S) { const c = document.createElement("canvas"); c.width = c.height = 256; const p = c.getContext("2d"); const img = p.createImageData(256, 256); for (let i = 0; i < img.data.length; i += 4) { const v = 118 + Math.random() * 96; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; } p.putImageData(img, 0, 0); S.grain = c; } function stepChoreo(S, now) { const t = now - S.t0; if (S.phase === "climb") { // the amp warming up — zen; it can never leave the green on its own S.level = 6.2 * (1 - Math.exp(-t / 5000)); } else if (S.phase === "armed") { // finishBootScene ran; honouring the quote's read + the smoke's fade-in S.level = 6.2 * (1 - Math.exp(-t / 5000)); if (now >= S.punchAt) { S.readyT = now; S.levelAtReady = S.level; S.phase = "punch"; } } else if (S.phase === "punch") { // 88 miles per hour: an accelerating slam, green to eleven in one gesture const k = Math.min(1, (now - S.readyT) / PUNCH); const L = S.levelAtReady + (11 - S.levelAtReady) * Math.pow(k, 1.8); S.level = Math.min(10, L); S.elevenGlow = Math.max(0, L - 10); if (k >= 1) { S.readyT = now; S.phase = "hold"; } } else if (S.phase === "hold") { S.level = 10; S.elevenGlow = 1; if (now - S.readyT >= HOLD) { S.readyT = now; S.phase = "bow"; } } else if (S.phase === "bow") { // first fade: the scene melts to the bare cover, still at eleven S.level = 10; S.elevenGlow = 1; S.fade = Math.min(1, (now - S.readyT) / BOW); } } function dealQuote(S, now) { S.quote = nextQuote(); S.qStart = now; if (S.els.qText) S.els.qText.textContent = S.quote[0]; if (S.els.qAttr) S.els.qAttr.textContent = S.quote[1]; } function stepQuotes(S, now) { if (S.phase !== "climb") return; // exit under way — the card on stage is the last if (S.qStart == null) { // the quote's stage opens once the brand has had its fade and its beat — // a boot that finishes sooner never shows one if (S.brandAt != null && now - S.brandAt >= BRAND_FADE + BRAND_DWELL) dealQuote(S, now); return; } // reduced motion holds its one considered card; otherwise the deck cycles if (!S.reduced && now - S.qStart >= Q_FADE * 2 + Q_HOLD) dealQuote(S, now); } // The under-glow: both road strips drawn as nested soft trapezoids into a // LOW-RES offscreen (plus a blur), upscaled onto the cover — the upscale is // what keeps the light diffuse and seam-free. function drawGlow(S) { const { g, W, H, room } = S; const scale = H * SCALE_TOP; if ((S.level + S.elevenGlow) / 11 <= 0.005) return; const K = 14; const gw = Math.max(2, Math.round(W / K)), gh = Math.max(2, Math.round(H / K)); if (!S.glowCv) S.glowCv = document.createElement("canvas"); if (S.glowCv.width !== gw || S.glowCv.height !== gh) { S.glowCv.width = gw; S.glowCv.height = gh; } const p = S.glowCv.getContext("2d"); p.setTransform(gw / W, 0, 0, gh / H, 0, 0); p.clearRect(0, 0, W, H); p.globalCompositeOperation = "lighter"; p.filter = "blur(3px)"; // ~40px of softness once upscaled; harmless where unsupported const stripCentre = (side, u) => W / 2 + side * W * ROAD_OFF * (1 - ROAD_CONV * clamp01(u)); const stripSigma = (u) => W * ROAD_SIGMA * (1 - 0.95 * clamp01(u)); // needles at the far end // the shared colour ramp along the road: green near, amber, red, eleven far const grad = p.createLinearGradient(0, H, 0, H - scale); for (let i = 0; i <= 26; i++) { const u = i / 26; const l = bandLight(u, S.level, S.elevenGlow); let r = 0, gr = 0, b = 0, sum = 0; for (let c = 0; c < 4; c++) { r += room.leds[c][0] * l[c]; gr += room.leds[c][1] * l[c]; b += room.leds[c][2] * l[c]; sum += l[c]; } if (sum > 0.001) { r /= sum; gr /= sum; b /= sum; } grad.addColorStop(u, `rgba(${r | 0},${gr | 0},${b | 0},${(Math.min(1, sum) * room.glowA).toFixed(3)})`); } p.fillStyle = grad; for (const side of [-1, 1]) { const cxb = stripCentre(side, 0), cxt = stripCentre(side, 1); const wb = stripSigma(0) * 1.7, wt = stripSigma(1) * 1.7; for (const [m, a] of [[3.6, 0.11], [2.1, 0.22], [1.0, 0.28]]) { p.globalAlpha = a; p.beginPath(); p.moveTo(cxb - wb * m, H + 4); p.lineTo(cxb + wb * m, H + 4); p.lineTo(cxt + wt * m, H - scale); p.lineTo(cxt - wt * m, H - scale); p.closePath(); p.fill(); } } p.globalAlpha = 1; // hot pools where the strips enter the frame, in the near corners const gl = room.leds[0]; const sI = Math.min(1, (S.level + S.elevenGlow) / 1.5) * (1 + S.elevenGlow * 0.5); for (const side of [-1, 1]) { const cx = stripCentre(side, 0); const pool = p.createRadialGradient(cx, H + 4, 0, cx, H + 4, W * 0.1); pool.addColorStop(0, `rgba(${gl[0]},${gl[1]},${gl[2]},${(room.spillA * sI).toFixed(3)})`); pool.addColorStop(1, `rgba(${gl[0]},${gl[1]},${gl[2]},0)`); p.fillStyle = pool; p.save(); p.translate(cx, H + 4); p.scale(1, 0.32); p.beginPath(); p.arc(0, 0, W * 0.1, 0, 7); p.fill(); p.restore(); } p.filter = "none"; g.save(); g.globalCompositeOperation = room.comp; g.imageSmoothingEnabled = true; g.drawImage(S.glowCv, 0, 0, W, H); g.restore(); } // Per-colour light at a point: the vertical band climb times the two strips' // sideways falloff (they narrow and converge with distance). function lightAt(S, x, y) { const u = (S.H - y) / (S.H * SCALE_TOP); const out = bandLight(u, S.level, S.elevenGlow); const s = S.W * ROAD_SIGMA * (1 - 0.95 * clamp01(u)); const cOff = S.W * ROAD_OFF * (1 - ROAD_CONV * clamp01(u)); const d1 = (x - (S.W / 2 - cOff)) / s; const d2 = (x - (S.W / 2 + cOff)) / s; const f = Math.min(1, Math.exp(-d1 * d1) + Math.exp(-d2 * d2)); if (f < 0.01) return [0, 0, 0, 0]; for (let c = 0; c < 4; c++) out[c] *= f; return out; } function drawSmoke(S, dt) { const { g, W } = S; g.save(); g.globalCompositeOperation = S.room.comp; for (const p of S.puffs) { if (!S.reduced) { p.x += p.vx * dt; p.rot += p.vr * dt; p.wob += dt * 0.55; if (p.x < -p.s) p.x = W + p.s * 0.6; else if (p.x > W + p.s) p.x = -p.s * 0.6; } const y = p.y + Math.sin(p.wob) * 20; // sample a little BELOW the puff's centre — lit-from-beneath undersides const light = lightAt(S, p.x, y + p.s * 0.22); const sp = S.sprites[p.spi]; g.save(); g.translate(p.x, y); g.rotate(p.rot); g.globalAlpha = p.a * S.room.neutralA; // its own presence, even unlit g.drawImage(sp.neutral, -p.s / 2, -p.s / 2, p.s, p.s); for (let c = 0; c < 4; c++) { const a = p.a * light[c] * (c === 3 ? 0.9 : 0.55); if (a < 0.005) continue; g.globalAlpha = Math.min(0.65, a); g.drawImage(sp.lit[c], -p.s / 2, -p.s / 2, p.s, p.s); } g.restore(); } g.restore(); } function drawMotes(S, dt, now) { const { g, W, H, room } = S; g.save(); for (const m of S.motes) { if (!S.reduced) { m.y += m.vy * dt; m.ph += dt * 0.9; if (m.y < -4) { m.y = H + 4; m.x = Math.random() * W; } } const x = m.x + Math.sin(m.ph) * m.sway; const light = lightAt(S, x, m.y); const tw = 0.65 + 0.35 * Math.sin(now * 0.001 * m.tw + m.ph * 3); let sum = 0, r = room.mote[0], gr = room.mote[1], b = room.mote[2]; for (let c = 0; c < 4; c++) sum += light[c]; let a; if (room.moteDark) { // lit room: dark dust, always faintly there, warmed by nearby colour a = (0.36 + Math.min(0.3, sum * 0.5)) * tw; for (let c = 0; c < 4; c++) { r += (room.leds[c][0] - r) * light[c] * 0.25; gr += (room.leds[c][1] - gr) * light[c] * 0.25; b += (room.leds[c][2] - b) * light[c] * 0.25; } } else { // dark room: a faint ambient glint, catching fire crossing a strip g.globalCompositeOperation = "lighter"; a = Math.min(0.95, 0.12 + sum * 1.1) * tw; for (let c = 0; c < 4; c++) { r += (room.leds[c][0] - r) * Math.min(1, light[c] * 1.4); gr += (room.leds[c][1] - gr) * Math.min(1, light[c] * 1.4); b += (room.leds[c][2] - b) * Math.min(1, light[c] * 1.4); } } if (a < 0.015) continue; g.globalAlpha = a; g.fillStyle = `rgb(${Math.min(255, r | 0)},${Math.min(255, gr | 0)},${Math.min(255, b | 0)})`; g.beginPath(); g.arc(x, m.y, m.r, 0, 7); g.fill(); } g.restore(); } function drawFrame(S, dt, now) { const { g, W, H } = S; g.clearRect(0, 0, W, H); // transparent: the cover's own --bg shows through drawGlow(S); drawSmoke(S, dt); drawMotes(S, dt, now); if (S.grain) { g.save(); g.globalAlpha = 0.045; g.globalCompositeOperation = "overlay"; const ox = -((Math.random() * 256) | 0), oy = -((Math.random() * 256) | 0); for (let x = ox; x < W; x += 256) for (let y = oy; y < H; y += 256) g.drawImage(S.grain, x, y); g.restore(); } // (the DOM chrome and the canvas' own staged opacity are stepCover's job) } poseBootCover();