| index 2476f01..c0b3bfa 100644 |
| --- a/web/static/bootscene.js |
| +++ b/web/static/bootscene.js |
| @@ -10,7 +10,12 @@ |
| // |
| // The instrument is never shown. The light is the whole story. |
| // |
| -// shell.js drives it through two calls, the same seams the old amp knob had: |
| +// Loading is two-stage so the cover is never blank: index.html loads this |
| +// module DIRECTLY, and on evaluation it poses the cover's chrome — avatar, |
| +// name, spinner, first quote, footer — instantly (poseBootCover, session |
| +// pages only). The smoke and lights need the full shell, so they arrive with |
| +// boot() and fade in over the chrome. 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() — punch to eleven; returns how long the finale needs |
| // |
| @@ -192,26 +197,23 @@ function tinted(shape, rgb) { |
| return c; |
| } |
| |
| -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 }; |
| +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"), |
| + }; |
| +} |
| |
| - // Branding: the letter tile is posed from the (already substituted) group |
| - // name; the real photo replaces it if the instance has one set. |
| +// 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"); |
| @@ -231,6 +233,66 @@ export function startBootScene() { |
| img.src = gr.avatar ? "/api/server-avatar" : "/static/icon-192.png"; |
| }) |
| .catch(() => {}); |
| +} |
| + |
| +// Stage one: the chrome, posed the instant this module lands — long before |
| +// the shell's import graph has arrived. 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; |
| + const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| + chrome = { t0: performance.now(), qStart: null, quote: null, phase: "climb", els: coverEls(), reduced, raf: 0 }; |
| + poseBranding(cover); |
| + if (reduced) { |
| + dealQuote(chrome, performance.now()); |
| + const o = [[chrome.els.brand, "1"], [chrome.els.footer, "0.8"], [chrome.els.spin, "0.85"], [chrome.els.quote, "0.9"]]; |
| + for (const [el, v] of o) if (el) el.style.opacity = v; |
| + return; |
| + } |
| + const tick = (now) => { |
| + if (scene || !canvas.isConnected) return; // the scene took over / cover gone |
| + stepQuotes(chrome, now); |
| + const rise = Math.min(1, (now - chrome.t0) / 350); |
| + const els = chrome.els; |
| + if (els.brand) els.brand.style.opacity = rise.toFixed(3); |
| + if (els.footer) els.footer.style.opacity = (0.8 * rise).toFixed(3); |
| + if (els.spin) els.spin.style.opacity = "0.85"; |
| + if (els.quote) |
| + els.quote.style.opacity = (chrome.qStart == null ? 0 : quoteOpacity(now - chrome.qStart, Q_FADE, Q_HOLD)).toFixed(3); |
| + 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 |
| |
| 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)) })); |
| @@ -250,28 +312,25 @@ export function startBootScene() { |
| elevenGlow: 0, |
| fade: 0, |
| phase: "climb", |
| - t0: performance.now(), |
| + // Adopt the chrome stage's clocks: the climb reflects how long the member |
| + // has ACTUALLY been waiting (so the light fades in already part-way up |
| + // the green), and the quote on stage carries over mid-cycle. |
| + t0: chrome ? chrome.t0 : performance.now(), |
| + sceneT0: performance.now(), // when the lights themselves arrived (fade-in) |
| readyT: 0, |
| levelAtReady: 0, |
| raf: 0, |
| glowCv: null, |
| grain: null, |
| - // quotes: the first deals as the scene opens, then ~12s cards forever |
| - qStart: null, |
| - quote: null, |
| - els: { |
| - 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"), |
| - }, |
| + qStart: chrome ? chrome.qStart : null, |
| + quote: chrome ? chrome.quote : null, |
| + els: coverEls(), |
| }; |
| - scene = S; |
| - // The scene elements are CSS-hidden until posed; the canvas is revealed |
| - // here once (the DOM text layers ease in per-frame from drawFrame). |
| - canvas.style.opacity = "1"; |
| + 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); |
| @@ -284,7 +343,7 @@ export function startBootScene() { |
| if (reduced) { |
| // A single considered still — mid-green, everything at rest, first quote up. |
| S.level = 5; |
| - dealQuote(S, performance.now()); |
| + if (S.qStart == null) dealQuote(S, performance.now()); |
| if (S.els.quote) S.els.quote.style.opacity = "0.9"; |
| drawFrame(S, 0, performance.now()); |
| return; |
| @@ -642,5 +701,10 @@ function drawFrame(S, dt, now) { |
| if (els.spin) els.spin.style.opacity = S.phase === "climb" || S.phase === "armed" ? "0.85" : "0"; |
| if (els.quote && !S.reduced) |
| els.quote.style.opacity = ((S.qStart == null ? 0 : quoteOpacity(now - S.qStart, Q_FADE, Q_HOLD)) * out).toFixed(3); |
| - S.canvas.style.opacity = out.toFixed(3); |
| + // The smoke and lights arrive after the chrome (they need the full shell) — |
| + // fade them in rather than popping, then out with the bow. |
| + const litIn = S.reduced ? 1 : Math.min(1, (now - S.sceneT0) / 700); |
| + S.canvas.style.opacity = (litIn * out).toFixed(3); |
| } |
| + |
| +poseBootCover(); |