eleven / ocho Public

web: boot cover chrome appears instantly; smoke and lights fade in when ready (#452)

The whole cover — even the server name already sitting in the HTML — was
posed by startBootScene, which only runs after shell.js's full import graph
arrives; on a slow or cold load that's a blank page. Loading is now two
stages: index.html loads bootscene.js DIRECTLY (one small file, the same
module instance shell.js later imports), and on evaluation it poses the
chrome — avatar, name, spinner, first quote, footer — immediately
(session pages only; the native embed and signed-out visitors are skipped).
When boot() runs, the scene adopts the chrome's clocks — the climb reflects
how long the member actually waited, the on-stage quote carries over
mid-cycle — and the smoke and lights fade in over 700ms instead of popping.

poseBootCover no-ops without a DOM so the Node tests can keep importing the
module's pure helpers.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 97dc54bd6f74398d0f6b9a90b3c8b616ea7f0337 parent c9cf3c1
2 files changed, +108 −38
  • web/pages/index.html +6 −0
  • web/static/bootscene.js +102 −38
diff --git a/web/pages/index.html b/web/pages/index.html
index 067956d..0d6bdd6 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -269,4 +269,10 @@
</div>
</template>
+<!-- bootscene.js is loaded DIRECTLY (not just via shell.js's import graph) so
+ the cover's chrome — avatar, name, spinner, quote, footer — appears the
+ moment this one small file lands; the smoke and lights fade in later,
+ when the shell has booted. Module caching makes this the same instance
+ shell.js imports. -->
+<script type="module" src="/static/bootscene.js"></script>
<script type="module" src="/static/shell.js"></script>
diff --git a/web/static/bootscene.js b/web/static/bootscene.js
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();