eleven / ocho Public

web: assemble the boot cover in stages, each earning its dwell (#832)

The local message store (#457) made most boots near-instant, which turned
the boot cover's quote into a flash yanked away mid-fade. The cover now
assembles in stages, each opening only if the app is STILL loading when the
previous one has had its look: a bare spinner; at 500ms the brand fades in
(350ms); after a 500ms beat, a quote; after its fade (2200ms) + 1000ms read,
the smoke and lights. Entering a stage commits the cover to staying up long
enough for that stage to be seen (the new pure coverWait helper, Node-tested)
— a boot that finishes mid-assembly freezes it where it stands and pays only
the dwell owed, and only a boot that reached the full scene earns the punch
to eleven (which now also waits out the smoke's own fade-in). Rolling quotes
keep their existing per-card read guarantee (MIN_READ = fade + 1000ms).

finishBootScene is now truly idempotent — repeat hideLoading calls (the
cached-paint path calls it twice) report the remaining hold instead of 0, so
a second caller can't cut the first one's dwell short. Reduced motion gets
the same stages as snaps instead of fades, drawn as the one considered still.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3118479fa06ccbc3d46b7d8ed0dd559181b6670a parent 226aa05
5 files changed, +161 −81
  • changelog/2026-07-13-staged-boot-loader.md +5 −0
  • test/bootscene.test.mjs +20 −2
  • web/pages/index.html +5 −4
  • web/static/bootscene.js +126 −72
  • web/static/shell.js +5 −3
diff --git a/changelog/2026-07-13-staged-boot-loader.md b/changelog/2026-07-13-staged-boot-loader.md
new file mode 100644
index 0000000..c0085d5
--- /dev/null
+++ b/changelog/2026-07-13-staged-boot-loader.md
@@ -0,0 +1,5 @@
+🤖 The loading screen now reveals itself in stages. Quick opens keep it to a
+plain spinner; the longer a load takes, the more appears — your space's photo
+and name, then a quote, then the smoke and lights — and anything that appears
+stays long enough to actually take in. No more quotes snatched away
+mid-sentence when the app opens fast.
diff --git a/test/bootscene.test.mjs b/test/bootscene.test.mjs
index 7505de6..e36c6fc 100644
--- a/test/bootscene.test.mjs
+++ b/test/bootscene.test.mjs
@@ -1,7 +1,7 @@
// Tests for the boot scene's pure timing/geometry (the light's band math, the
-// quote fade envelope, the reading-time guarantee). node test/bootscene.test.mjs
+// quote fade envelope, the staged cover's dwell guarantees). node test/bootscene.test.mjs
import assert from "node:assert/strict";
-import { BANDS, bandLight, quoteOpacity, finishWait } from "../web/static/bootscene.js";
+import { BANDS, bandLight, quoteOpacity, finishWait, coverWait } from "../web/static/bootscene.js";
let failures = 0;
async function test(name, fn) {
@@ -88,4 +88,22 @@ test("reading-time guarantee: a fresh quote delays the punch, an old one doesn't
assert.equal(finishWait(60000, 4500), 0, "a long-displayed quote owes nothing");
});
+test("staged cover: nothing assembled → no dwell owed", () => {
+ assert.equal(coverWait(null, null), 0, "a fast boot fades before any stage enters");
+});
+
+test("staged cover: an entered brand gets its fade plus a beat", () => {
+ assert.equal(coverWait(0, null), 850, "just entered: full fade (350) + dwell (500)");
+ assert.equal(coverWait(600, null), 250);
+ assert.equal(coverWait(850, null), 0, "dwell spent — exactly when the quote's stage may open");
+ assert.equal(coverWait(60000, null), 0);
+});
+
+test("staged cover: an entered quote gets its fade plus a read, subsuming the brand", () => {
+ assert.equal(coverWait(9999, 0), 3200, "just dealt: full fade (2200) + read (1000)");
+ assert.equal(coverWait(9999, 2200), 1000, "fully faded in — the read is still owed");
+ assert.equal(coverWait(9999, 3200), 0, "read spent — exactly when the smoke's stage may open");
+ assert.equal(coverWait(null, 0), 3200, "the quote's guarantee stands on its own");
+});
+
process.exit(failures ? 1 : 0);
diff --git a/web/pages/index.html b/web/pages/index.html
index 023b3b1..5a29176 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -85,10 +85,11 @@
<div id="app-loading" class="app-loading">
<!-- The boot scene (bootscene.js): smoke and dust in a haze-filled room, lit
from below by an UNSEEN 11-segment meter — two strips of light from the
- bottom corners converging behind the server's avatar. Green drifts up
- while loading; at ready it slams to eleven and bows out. The children
- stay hidden until startBootScene poses them (.scene-on) so the embed and
- signed-out paths, which never start the scene, show only the plain bg. -->
+ bottom corners converging behind the server's avatar. The cover
+ assembles in stages as a boot takes longer (spinner → brand → quote →
+ smoke); at ready it slams to eleven and bows out. The children stay
+ hidden (opacity 0) until bootscene.js poses them, so the embed and
+ signed-out paths, which never run the scene, show only the plain bg. -->
<canvas class="boot-canvas" id="boot-canvas" aria-hidden="true"></canvas>
<div class="boot-brand" id="boot-brand">
<div class="boot-avatar" id="boot-avatar-tile" aria-hidden="true">
diff --git a/web/static/bootscene.js b/web/static/bootscene.js
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();
diff --git a/web/static/shell.js b/web/static/shell.js
index 501cc29..0fddf56 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1261,12 +1261,14 @@ async function boot() {
}
// Fade out and remove the full-screen loading cover (shown until the app's first
-// meaningful paint). Idempotent — safe to call from any entry path. If a quote
-// is mid-display on the boot scene, the reveal waits until it's been readable.
+// meaningful paint). Idempotent — safe to call from any entry path. The cover
+// assembles in stages (bootscene.js), and the reveal waits out whatever it had
+// time to show: a posed brand gets its beat, a quote its read, the full scene
+// its punch to eleven.
function hideLoading() {
const el = document.getElementById("app-loading");
if (!el) return;
- const clicked = finishBootScene(); // the scene punches to eleven — let it land
+ const clicked = finishBootScene(); // the dwell the staged cover is owed — let it land
setTimeout(() => {
// cover-out, NOT the global .hidden — that's display:none !important and
// would snap the cover off with no cross-fade.