| index c0b3bfa..61c2609 100644 |
| --- a/web/static/bootscene.js |
| +++ b/web/static/bootscene.js |
| @@ -4,20 +4,27 @@ |
| // 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 |
| -// slam politely waits until the quote on stage has been readable — even a |
| -// fast boot gives the first quote its moment. |
| +// 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. |
| // |
| -// 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 |
| +// 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() — punch to eleven; returns how long the finale needs |
| +// 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. |
| @@ -66,6 +73,18 @@ export function finishWait(quoteAgeMs, minReadMs) { |
| 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 |
| @@ -153,9 +172,17 @@ 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 MIN_READ = 3200; // a quote on stage gets at least this before the punch |
| 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]]; |
| @@ -235,9 +262,42 @@ function poseBranding(cover) { |
| .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. |
| +// 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 |
| @@ -251,24 +311,11 @@ function poseBootCover() { |
| } 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 }; |
| + chrome = { t0: performance.now(), brandAt: null, qStart: null, quote: null, phase: "climb", els: coverEls(), reduced, fade: 0, 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); |
| + stepCover(chrome, now); |
| chrome.raf = requestAnimationFrame(tick); |
| }; |
| chrome.raf = requestAnimationFrame(tick); |
| @@ -311,19 +358,22 @@ export function startBootScene() { |
| level: 0, |
| elevenGlow: 0, |
| fade: 0, |
| - phase: "climb", |
| - // 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. |
| + // 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(), |
| - sceneT0: performance.now(), // when the lights themselves arrived (fade-in) |
| + 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, |
| - qStart: chrome ? chrome.qStart : null, |
| - quote: chrome ? chrome.quote : null, |
| els: coverEls(), |
| }; |
| scene = S; // set before cancelling the chrome ticker — its guard checks this |
| @@ -335,47 +385,63 @@ export function startBootScene() { |
| 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, first quote up. |
| + // 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; |
| - 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; |
| } |
| 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; |
| - stepChoreo(S, now); |
| - stepQuotes(S, now); |
| - drawFrame(S, dt, 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); |
| } |
| |
| -// Punch to eleven. Returns how long hideLoading must hold the cover: any |
| -// reading-time owed to the quote on stage, plus the punch/hold/bow. The exit |
| -// is two quick fades: the scene fades to the bare cover (the bow), then the |
| -// cover itself fades into the chat (.scene-out). |
| +// 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; |
| - if (!S) return 0; // boot paths that never showed the scene just fade |
| - if (S.reduced) return 300; |
| - if (S.phase !== "climb") return 0; // already finishing (idempotent-ish) |
| + const S = scene || chrome; |
| + if (!S) return 0; // boot paths that never posed the cover just fade |
| const now = performance.now(); |
| - const wait = finishWait(S.qStart == null ? null : now - S.qStart, MIN_READ); |
| + 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"); |
| - return Math.round(wait + PUNCH + HOLD + BOW + REST); |
| + const total = Math.round(wait + PUNCH + HOLD + BOW + REST); |
| + S.exitAt = now + total; |
| + return total; |
| } |
| |
| function sizeScene(S) { |
| @@ -443,7 +509,7 @@ function stepChoreo(S, now) { |
| // 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; we're honouring the quote's reading time |
| + // 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; |
| @@ -483,13 +549,15 @@ function dealQuote(S, now) { |
| } |
| |
| function stepQuotes(S, now) { |
| - // The first quote opens with the scene — even a fast boot holds the punch |
| - // until it's been readable (finishWait), so every load gets its line. |
| + if (S.phase !== "climb") return; // exit under way — the card on stage is the last |
| if (S.qStart == null) { |
| - if (S.phase === "climb" || S.phase === "armed") dealQuote(S, now); |
| + // 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; |
| } |
| - if (now - S.qStart >= Q_FADE * 2 + Q_HOLD && (S.phase === "climb" || S.phase === "armed")) dealQuote(S, now); |
| + // 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 |
| @@ -690,21 +758,7 @@ function drawFrame(S, dt, now) { |
| for (let x = ox; x < W; x += 256) for (let y = oy; y < H; y += 256) g.drawImage(S.grain, x, y); |
| g.restore(); |
| } |
| - // DOM layers: brand eases in at the start and takes the bow with the scene; |
| - // the spinner leaves at the punch (work is done — the light says the rest); |
| - // the quote runs its own, much slower clock. |
| - const rise = Math.min(1, (now - S.t0) / 350); |
| - const out = 1 - S.fade; |
| - const els = S.els; |
| - if (els.brand) els.brand.style.opacity = (rise * out).toFixed(3); |
| - if (els.footer) els.footer.style.opacity = (0.8 * rise * out).toFixed(3); |
| - 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); |
| - // 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); |
| + // (the DOM chrome and the canvas' own staged opacity are stepCover's job) |
| } |
| |
| poseBootCover(); |