eleven / messenger Public

web: boot scene — smoke, light, and a meter that goes to eleven (#441)

* web: boot scene — smoke, light, and a meter that goes to eleven

Replace the shaking amp-knob loader with the calm scene designed with Paul:
smoke and rising dust in a haze-filled room, lit from below by an UNSEEN
11-segment meter — two soft strips of light from the bottom corners
converging to needles behind the server's avatar. While the app loads, the
level drifts up through the green (asymptote 6.2 — it can never leave the
green on its own); the moment boot finishes it slams to eleven in 190ms,
red igniting behind the avatar, holds a beat, and bows out.

On the cover: the server's photo + name (the join-card lockup, branding is
already plaintext by design), a zen 2.4s spinner dead centre, a slow deck
of quotes in the lower third (~12s each; order + pointer persist in
localStorage so no repeats until the library exhausts), and a small
"This group chat goes to Eleven" link at the bottom.

Two production adaptations vs the prototype: quotes only appear after 1.8s
of genuine loading, so a fast boot never owes reading time — and when a
quote IS on stage, the punch waits until it's been readable (≥4.5s from
fade-in). Both rooms supported (dark: white smoke, additive light; light
themes: dark grey smoke, deeper LED inks), judged from the resolved page
background like the old dust. Reduced motion gets a single still.

Same seams as before: boot() starts the scene, hideLoading() finishes it
and holds the cover for the returned finale duration; embed/signed-out/
A2HS paths never start it and fade a plain cover. The scene's pure band
math + quote timing are exported and tested in test/bootscene.test.mjs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: unbranded instances show the Eleven mark on the landing card + boot lockup

No server photo and no admin-chosen name (/api/group name_set=false) now
falls back to /static/icon-192.png — the canonical app icon — instead of a
bare card / a letter tile spelling the compiled-in default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the first quote opens with the scene — a fast boot holds the punch for it

Previously no quote appeared before 1.8s of loading, so a quick reload
punched immediately with nothing to read. Paul wants the line to always
land: the first quote now deals as the scene opens, and the punch waits
until it's been readable (>=4.5s from fade-in) regardless of load speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: drop stale QUOTE_DELAY comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: quicker green climb (τ 7s → 5s), shorter quote read floor (4.5s → 3.2s)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the scene cross-fades into the chat — no bow to a blank cover

The finale used to fade the scene to the bare cover bg (750ms) and then
fade the cover (250ms) — a grey beat between eleven and the chat. Now the
scene stays at full eleven and the COVER itself cross-fades (0.7s via
.scene-out; join.js keeps its 0.25s), so the red glow melts directly into
the threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: fix invisible scene — the canvas reveal died with the bow refactor

The cover's CSS hides every scene element at opacity 0 until the engine
poses it; the canvas used to be revealed by the per-frame fade write that
the cross-fade refactor removed. Reveal it once at start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: cover exits via an opacity-only class — the global .hidden snaps

.hidden is display:none !important, so el.classList.add("hidden") in
hideLoading never transitioned — the cover always popped off; the old
loader masked it by fading itself to a bare cover first. The cover now
exits via .cover-out (opacity only), so the cross-fade into the chat
actually runs. join.js keeps its .hidden toggle (it wants the snap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: two-fade exit — the scene bows to the cover (320ms), the cover fades to the chat (350ms)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: tighter eleven beat (400ms), distinct two-step exit (bow, breath, cover fade)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ff25e6d7232ac6128afdcdd398c065a46a2345ae parent 2218a19
7 files changed, +882 −255
  • changelog/2026-07-08-boot-scene.md +5 −0
  • package.json +1 −1
  • test/bootscene.test.mjs +91 −0
  • web/pages/index.html +19 −11
  • web/static/bootscene.js +646 −0
  • web/static/shell.js +21 −194
  • web/static/style.css +99 −49
diff --git a/changelog/2026-07-08-boot-scene.md b/changelog/2026-07-08-boot-scene.md
new file mode 100644
index 0000000..939dcda
--- /dev/null
+++ b/changelog/2026-07-08-boot-scene.md
@@ -0,0 +1,5 @@
+🤖 Opening your space has a new look. While it loads you'll see your space's
+photo and name over drifting smoke, lit from below by a meter climbing gently
+through the green — and the moment everything's ready, it slams to eleven.
+There's also something to read while you wait: a slowly changing quote, and
+you'll never see the same one twice until you've seen them all.
diff --git a/package.json b/package.json
index 5ee212c..be238b0 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/bootscene.test.mjs b/test/bootscene.test.mjs
new file mode 100644
index 0000000..7505de6
--- /dev/null
+++ b/test/bootscene.test.mjs
@@ -0,0 +1,91 @@
+// 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
+import assert from "node:assert/strict";
+import { BANDS, bandLight, quoteOpacity, finishWait } from "../web/static/bootscene.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const sum = (l) => l[0] + l[1] + l[2] + l[3];
+
+test("bands cover the road in order: green, amber, red, eleven", () => {
+ assert.equal(BANDS.length, 5);
+ assert.equal(BANDS[0], 0);
+ assert.equal(BANDS[4], 1);
+ for (let i = 1; i < BANDS.length; i++) assert.ok(BANDS[i] > BANDS[i - 1]);
+});
+
+test("the soft frontier leads the level by a bounded margin", () => {
+ // The light fades out ~0.14 past the frontier instead of ending at a hard
+ // line. At level 0 that lead may kiss the very bottom of the road, but
+ // beyond it there must be nothing.
+ for (const u of [0.15, 0.2, 0.5, 0.9]) assert.equal(sum(bandLight(u, 0, 0)), 0);
+});
+
+test("the frontier gates the climb: light below it, none above", () => {
+ const l = 3; // frontier at 3/11
+ assert.ok(bandLight(0.1, l, 0)[0] > 0, "green near the source");
+ assert.equal(sum(bandLight(0.5, l, 0)), 0, "dark past the frontier");
+});
+
+test("the zen climb can never reach the red", () => {
+ // The climb asymptotes at level 6.2 (frontier 0.564 + soft lead 0.14 →
+ // light tops out below u=0.71): the frontier's bleed may warm the bottom
+ // of the amber, but red (from 9/11 = 0.818) only ever arrives with the
+ // punch.
+ for (const u of [0.71, 0.8, 0.9, 0.95]) {
+ assert.equal(sum(bandLight(u, 6.2, 0)), 0, `light found at u=${u} during climb`);
+ }
+});
+
+test("colour belongs to height, not to level: green stays green at ten", () => {
+ const low = bandLight(0.2, 10, 0);
+ assert.ok(low[0] > 0, "the bottom is still green at level ten");
+ assert.equal(low[1] + low[2] + low[3], 0, "no amber/red bleeding into the green band");
+ const mid = bandLight(0.7, 10, 0);
+ assert.ok(mid[1] > 0, "amber lives above the green");
+ const high = bandLight(0.88, 10, 0);
+ assert.ok(high[2] > 0, "red above the amber");
+});
+
+test("the eleventh band lights only with elevenGlow", () => {
+ const u = 0.96; // inside the eleven band
+ assert.equal(bandLight(u, 10, 0)[3], 0, "dark before the punch lands");
+ assert.ok(bandLight(u, 10, 1)[3] > 0, "lit at eleven");
+});
+
+test("light is hottest near the unseen source", () => {
+ const near = sum(bandLight(0.05, 10, 0));
+ const far = sum(bandLight(0.5, 10, 0));
+ assert.ok(near > far, "intensity must fall with distance up the road");
+});
+
+test("quote envelope: fade in, hold, fade out", () => {
+ const F = 2200, H = 7600;
+ assert.equal(quoteOpacity(0, F, H), 0);
+ assert.equal(quoteOpacity(F / 2, F, H), 0.5);
+ assert.equal(quoteOpacity(F, F, H), 1);
+ assert.equal(quoteOpacity(F + H / 2, F, H), 1);
+ assert.ok(Math.abs(quoteOpacity(F + H + F / 2, F, H) - 0.5) < 1e-9);
+ assert.equal(quoteOpacity(F + H + F, F, H), 0);
+ assert.equal(quoteOpacity(F + H + F + 500, F, H), 0, "stays 0 past the cycle");
+ assert.equal(quoteOpacity(-100, F, H), 0, "never negative-age light");
+});
+
+test("reading-time guarantee: a fresh quote delays the punch, an old one doesn't", () => {
+ assert.equal(finishWait(null, 4500), 0, "no quote on stage → punch immediately");
+ assert.equal(finishWait(0, 4500), 4500, "a quote that JUST appeared gets its full read");
+ assert.equal(finishWait(1000, 4500), 3500);
+ assert.equal(finishWait(4500, 4500), 0);
+ assert.equal(finishWait(60000, 4500), 0, "a long-displayed quote owes nothing");
+});
+
+process.exit(failures ? 1 : 0);
diff --git a/web/pages/index.html b/web/pages/index.html
index 25540a6..067956d 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -56,18 +56,26 @@
already set pre-paint above), so a refresh doesn't flash an empty shell and
then jump as messages load + scroll. shell.js removes it once ready. -->
<div id="app-loading" class="app-loading">
- <!-- The amp knob: labelled 1–11 like the volume knob on an amplifier. The pie
- fills and the pointer turns towards 10 while loading; when the app is
- ready it goes to eleven, holds a beat, and glows. (Labels + motion are
- built by startLoadingDial in shell.js.) -->
- <!-- Dust on the stage: particles along the bottom that bounce ever higher as
- the volume climbs — nearly to the top of the screen at eleven
- (see stepDust in shell.js). Decorative only. -->
- <canvas class="amp-dust" id="amp-dust" aria-hidden="true"></canvas>
- <div class="amp-knob" id="amp-knob" role="progressbar" aria-label="Loading">
- <div class="amp-fill" id="amp-fill"></div>
- <div class="amp-body"><div class="amp-pointer" id="amp-pointer"></div></div>
+ <!-- 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. -->
+ <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">
+ <span class="boot-avatar-letter"></span>
+ <img id="boot-avatar-img" alt="" hidden />
+ </div>
+ <div class="boot-name">__GROUP_NAME__</div>
+ </div>
+ <div class="boot-spin" id="boot-spin" role="progressbar" aria-label="Loading"></div>
+ <div class="boot-quote" id="boot-quote">
+ <div class="boot-quote-text" id="boot-quote-text"></div>
+ <div class="boot-quote-attr" id="boot-quote-attr"></div>
</div>
+ <a class="boot-goesto" id="boot-goesto" href="https://elevenmessenger.com" target="_blank" rel="noopener">This group chat goes to <b>Eleven</b></a>
</div>
<div id="toast" class="toast"></div>
diff --git a/web/static/bootscene.js b/web/static/bootscene.js
new file mode 100644
index 0000000..2476f01
--- /dev/null
+++ b/web/static/bootscene.js
@@ -0,0 +1,646 @@
+// 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
+// slam politely waits until the quote on stage has been readable — even a
+// fast boot gives the first quote its moment.
+//
+// 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:
+// startBootScene() — begin the climb (boot() path only; embed/no-invite skip)
+// finishBootScene() — punch to eleven; returns how long the finale needs
+//
+// 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 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.
+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"],
+];
+
+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 !== QUOTES.length || state.i >= state.order.length) {
+ const order = QUOTES.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 = QUOTES[state.order[state.i]] || QUOTES[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 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
+
+// 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;
+}
+
+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 };
+
+ // Branding: the letter tile is posed from the (already substituted) group
+ // name; the real photo replaces it if the instance has one set.
+ 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(() => {});
+
+ 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,
+ phase: "climb",
+ t0: performance.now(),
+ 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"),
+ },
+ };
+ 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";
+ sizeScene(S);
+ S.onResize = () => {
+ sizeScene(S);
+ seedScene(S);
+ };
+ addEventListener("resize", S.onResize);
+ seedScene(S);
+ makeGrain(S);
+
+ if (reduced) {
+ // A single considered still — mid-green, everything at rest, first quote up.
+ S.level = 5;
+ 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);
+ 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).
+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 now = performance.now();
+ const wait = finishWait(S.qStart == null ? null : now - S.qStart, MIN_READ);
+ S.punchAt = now + wait;
+ S.phase = "armed";
+ S.cover.classList.add("scene-out");
+ return Math.round(wait + PUNCH + HOLD + BOW + REST);
+}
+
+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; we're honouring the quote's reading time
+ 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) {
+ // 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.qStart == null) {
+ if (S.phase === "climb" || S.phase === "armed") dealQuote(S, now);
+ return;
+ }
+ if (now - S.qStart >= Q_FADE * 2 + Q_HOLD && (S.phase === "climb" || S.phase === "armed")) 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();
+ }
+ // 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);
+ S.canvas.style.opacity = out.toFixed(3);
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 49ab94b..76872aa 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -45,6 +45,7 @@ import {
} from "/static/ui.js";
import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
+import { startBootScene, finishBootScene } from "/static/bootscene.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import {
setEmitTransport,
@@ -556,191 +557,9 @@ let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a bri
// dead zone and every custom app silently skips on a same-tick boot.
const CUSTOM_APPS_TIMEOUT_MS = 4000;
-// The boot amp knob (see index.html): labels 1–11 around a 300° pot (1 at 7
-// o'clock, 11 at 5 o'clock, 30° apart). While loading, the fill + pointer ease
-// from 1 towards 10 — and the knob starts to SHAKE as the volume climbs. The
-// finish is choreographed to feel like a human turning it, never instant:
-// hideLoading eases the rest of the way to 10 (a minimum total turn even on a
-// fast load), holds a beat, snaps to eleven, and lets it glow.
-// (All declared above route()'s synchronous boot() call — the TDZ rule.)
-let clockRaf = 0;
-let dialStarted = false; // did this boot show the amp knob? (embed/no-invite/A2HS don't)
-let dialT0 = 0;
-let dialSweep = 0;
-const DIAL_MIN_TURN_MS = 1800; // a fast load still gets a human-speed turn to 10
-// Stage dust: particles resting along the loader's bottom edge that the volume
-// bounces around — still at 1, leaping ever higher as the dial climbs, and at
-// ELEVEN launched to between the middle and the top of the viewport. The
-// canvas is full-height so nothing clips them.
-let dust = null;
-const DUST_GRAVITY = 0.62; // heavy, so hops are snappy
-// resolveThemeColor turns a CSS variable into a canvas-usable "rgb(…)": assigning
-// it to a real element's `color` is what makes the browser fully resolve it
-// (light-dark() included) before we read it back.
-function resolveThemeColor(varName) {
- const el = document.createElement("span");
- el.style.color = `var(${varName})`;
- document.body.appendChild(el);
- const c = getComputedStyle(el).color;
- el.remove();
- return c;
-}
-function initDust() {
- const canvas = document.getElementById("amp-dust");
- if (!canvas || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
- const dpr = window.devicePixelRatio || 1;
- const w = window.innerWidth;
- const h = window.innerHeight;
- canvas.width = w * dpr;
- canvas.height = h * dpr;
- const g = canvas.getContext("2d");
- if (!g) return;
- g.scale(dpr, dpr);
- // Dust must contrast with the floor it sits on: dark specks (muted) on a light
- // background, LIGHT specks (ink) on a dark one — muted-on-dark all but
- // vanishes. Judge dark by the RESOLVED page background (the user's theme +
- // color-scheme decide it, not the OS media query alone).
- // NB: reading a custom property directly returns the RAW `light-dark(…)` token,
- // which canvas fillStyle can't parse (it silently stays black — the dust was
- // black in every theme). resolveThemeColor() resolves through a real `color`
- // property instead.
- const bgRGB = ((getComputedStyle(document.body).backgroundColor || "").match(/\d+/g) || []).map(Number);
- const darkMode = bgRGB.length >= 3 && 0.299 * bgRGB[0] + 0.587 * bgRGB[1] + 0.114 * bgRGB[2] < 128;
- const ink = resolveThemeColor(darkMode ? "--ink" : "--muted") || "#888";
- const accent = resolveThemeColor("--accent") || "#7c7cf4";
- const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({
- x: Math.random() * w,
- y: h - 2 - Math.random() * 2,
- vx: 0,
- vy: 0,
- r: 0.8 + Math.random() * 2.1,
- c: Math.random() < 0.18 ? accent : ink,
- a: 0.3 + Math.random() * 0.5,
- }));
- // fade: the whole cloud's opacity — clickDialToEleven dims it during the bow
- // so the motes fade out with the knob, still mid-flight.
- dust = { g, w, h, parts, fade: 1 };
-}
-function stepDust(level) {
- if (!dust) return;
- const { g, w, h, parts } = dust;
- g.clearRect(0, 0, w, h);
- for (const p of parts) {
- // Volume kicks: both the odds and the violence grow with the level. Sharp
- // impulses + heavy gravity = a BOUNCE, not a float. Every kick is sized as
- // a fraction of the viewport height it should peak at: climbing the dial
- // the hops grow fast (level², so they're already leaping mid-climb), and
- // AT eleven (level > 1 — only the snap goes there) they reach between the
- // middle and the top of the page.
- if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * 0.2) {
- const peak =
- level > 1
- ? 0.5 + Math.random() * 0.4
- : level * level * (0.12 + Math.random() * 0.22);
- p.vy = -Math.sqrt(2 * DUST_GRAVITY * h * peak);
- p.vx += (Math.random() - 0.5) * 2.2 * level;
- }
- p.vy += DUST_GRAVITY;
- p.y += p.vy;
- p.x += p.vx;
- p.vx *= 0.97;
- if (p.y >= h - 2) {
- p.y = h - 2;
- p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; // crisp rebound, quick rest
- }
- if (p.x < -4) p.x += w + 8;
- else if (p.x > w + 4) p.x -= w + 8;
- g.globalAlpha = p.a * dust.fade;
- g.fillStyle = p.c;
- g.beginPath();
- g.arc(p.x, p.y, p.r, 0, 7);
- g.fill();
- }
- g.globalAlpha = 1;
-}
-function dialPaint(knob, fill, body, sweep, shakeScale) {
- fill.style.setProperty("--sweep", sweep.toFixed(1) + "deg");
- body.style.transform = `rotate(${(-150 + sweep).toFixed(1)}deg)`;
- // Loudness shake: nothing below ~5, building to a visible tremble by 10–11.
- const level = Math.max(0, (sweep - 120) / 180) * (shakeScale == null ? 1 : shakeScale);
- stepDust(level); // the stage dust dances to the same level
- if (level > 0 && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
- const a = 1.7 * level;
- knob.style.transform = `translate(${((Math.random() - 0.5) * 2 * a).toFixed(2)}px, ${((Math.random() - 0.5) * 2 * a).toFixed(2)}px) rotate(${((Math.random() - 0.5) * 1.6 * level).toFixed(2)}deg)`;
- } else {
- knob.style.transform = "";
- }
-}
-function startLoadingDial() {
- const knob = document.getElementById("amp-knob");
- const fill = document.getElementById("amp-fill");
- const body = knob && knob.querySelector(".amp-body");
- if (!knob || !fill || !body) return;
- dialStarted = true;
- for (let n = 1; n <= 11; n++) {
- const num = document.createElement("span");
- num.className = "amp-num";
- num.dataset.n = String(n);
- num.textContent = String(n);
- num.style.setProperty("--a", -150 + (n - 1) * 30 + "deg");
- knob.appendChild(num);
- }
- initDust();
- // Pose at 1 BEFORE revealing: paint the first frame, then fade the knob in
- // on the next frame so it never shows unposed.
- dialPaint(knob, fill, body, 0);
- requestAnimationFrame(() => knob.classList.add("ready"));
- dialT0 = Date.now();
- const tick = () => {
- const t = (Date.now() - dialT0) / 1000;
- dialSweep = 270 * (1 - Math.exp(-t / 2.5)); // 0° = volume 1 … 270° = volume 10, never arriving
- dialPaint(knob, fill, body, dialSweep);
- clockRaf = requestAnimationFrame(tick);
- };
- clockRaf = requestAnimationFrame(tick);
-}
-function clickDialToEleven() {
- if (!dialStarted) return 0; // boot paths that never showed the knob just fade
- const knob = document.getElementById("amp-knob");
- const fill = document.getElementById("amp-fill");
- const body = knob && knob.querySelector(".amp-body");
- if (!knob || !fill || !body) return 0;
- cancelAnimationFrame(clockRaf);
- // Choreography: finish the turn to 10 at human speed (topping the total turn
- // up to the minimum), hold a beat, SNAP to eleven, then glow — shaking harder
- // all the way, easing off as the glow fades.
- const toTen = Math.max(350, DIAL_MIN_TURN_MS - (Date.now() - dialT0));
- const beat = 850; // sit at 10, trembling — let the anticipation really build
- const eleven = 620; // AT eleven the shake goes wild (this IS the loudest it goes)
- const bow = 330; // …and it fades out still rattling, before the chat appears
- const CRANKED = 2.3; // shake multiplier at 11 — well past the 10 tremble
- const from = dialSweep;
- const t0 = Date.now();
- const run = () => {
- const t = Date.now() - t0;
- if (t < toTen) {
- const k = t / toTen;
- dialPaint(knob, fill, body, from + (270 - from) * (1 - (1 - k) * (1 - k)));
- } else if (t < toTen + beat) {
- dialPaint(knob, fill, body, 270);
- } else if (t < toTen + beat + eleven + bow) {
- // The SNAP: no travel, no ease — one frame it's at 10, the next it's AT
- // eleven, lit, shaking uncontrollably (and fading, during the bow).
- // The dust takes the bow with the knob: same window, fading while it
- // still rattles and flies. (Clamped to 1 until the bow starts.)
- if (dust) dust.fade = Math.min(1, Math.max(0, 1 - (t - (toTen + beat + eleven)) / bow));
- dialPaint(knob, fill, body, 300, CRANKED);
- knob.classList.add("pop");
- if (t >= toTen + beat + eleven) knob.classList.add("out");
- } else {
- knob.classList.add("out");
- return;
- }
- requestAnimationFrame(run);
- };
- requestAnimationFrame(run);
- return toTen + beat + eleven + bow;
-}
+// The boot loading scene (smoke, dust, and an unseen meter climbing to
+// eleven) lives in bootscene.js — boot() starts it, hideLoading() finishes it.
+// Paths that never start it (embed, signed-out, A2HS) get 0 back and just fade.
// If we've just returned from the home server's /restore page, adopt the
// recovered session before routing (checkRestoreReturn writes it to storage).
@@ -832,7 +651,7 @@ async function loadMemberAppsNow() {
async function boot() {
try {
- startLoadingDial();
+ startBootScene();
await loadCustomApps();
await ensurePubkey();
// Role + first-run state, fetched in parallel with the heavy loads:
@@ -892,15 +711,17 @@ 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 loading
-// line is mid-display, the reveal waits until it's been readable (≥1.1s).
+// 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.
function hideLoading() {
const el = document.getElementById("app-loading");
if (!el) return;
- const clicked = clickDialToEleven(); // the dial clicks to 11 — let it land
+ const clicked = finishBootScene(); // the scene punches to eleven — let it land
setTimeout(() => {
- el.classList.add("hidden");
- setTimeout(() => el.remove(), 250); // after the CSS fade
+ // cover-out, NOT the global .hidden — that's display:none !important and
+ // would snap the cover off with no cross-fade.
+ el.classList.add("cover-out");
+ setTimeout(() => el.remove(), 400); // after the CSS fade (0.35s with .scene-out)
}, clicked);
}
@@ -6816,12 +6637,18 @@ function showNoInvite() {
// Public branding: reveal the server photo once /api/group confirms one is
// set (the name is already substituted server-side). Same flag join.js uses.
fetch("/api/group").then((r) => r.json()).then((g) => {
- if (g && g.avatar) {
- const img = document.getElementById("join-brand-photo");
- if (!img) return;
+ const img = document.getElementById("join-brand-photo");
+ if (!img || !g) return;
+ if (g.avatar) {
img.alt = g.avatar_alt || "";
img.src = "/api/server-avatar";
img.classList.remove("hidden");
+ } else if (!g.name_set) {
+ // No photo AND no admin-chosen name: an unbranded instance. Show the
+ // Eleven mark rather than a bare default — never a hand-rolled glyph.
+ img.alt = "";
+ img.src = "/static/icon-192.png";
+ img.classList.remove("hidden");
}
}).catch(() => {});
const linkInput = document.getElementById("link");
diff --git a/web/static/style.css b/web/static/style.css
index 3d73148..06bb7b7 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -163,6 +163,13 @@ html, body {
transition: opacity 0.25s ease;
}
.app-loading.hidden { opacity: 0; pointer-events: none; }
+/* The cover's EXIT class (hideLoading): opacity-only, unlike the global
+ `.hidden` utility whose display:none !important would snap the cover off
+ with no transition. The boot scene's exit is two quick fades — the scene
+ bows to the bare cover (bootscene.js), then this fades the cover to the
+ chat (.scene-out tunes the second fade's length). */
+.app-loading.cover-out { opacity: 0; pointer-events: none; }
+.app-loading.scene-out { transition-duration: 0.35s; }
/* The join page's "Plugging in…" flash between claiming and the app. */
.plugging-in {
min-height: 22px;
@@ -174,74 +181,117 @@ html, body {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.plugging-in.show { opacity: 1; transform: none; }
-/* The loader's stage dust: particles resting along the bottom edge. The canvas
- covers the whole viewport so the eleven-snap bounces can clear the top. */
-.amp-dust {
+/* The boot scene (bootscene.js): its elements start invisible and are posed
+ per-frame from JS (ease-in at start, the bow at the end) — so the embed and
+ signed-out paths, which never start the scene, only ever show the plain
+ theme bg. (Scoped to the scene's own classes: join.html shares .app-loading
+ for its plain spinner.) The rules here are layout + type only. */
+.boot-canvas, .boot-brand, .boot-spin, .boot-quote, .boot-goesto { opacity: 0; }
+.boot-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
-/* The boot amp knob (labelled 1–11): the pie arc fills and the pointer turns
- from 1 towards 10 while loading; on ready it goes to eleven, then glows.
- The pot sweeps 300° — 1 at 7 o'clock round to 11 at 5 o'clock. */
-.amp-knob {
- width: 92px;
- height: 92px;
- border-radius: 50%;
- position: relative;
- /* Hidden until the engine has posed it at 1 (labels built, pointer turned) —
- the raw pre-JS knob would flash pointing at nothing, mid-dial. */
- opacity: 0;
+/* Whose space this is — the same photo + name lockup as the join card. */
+.boot-brand {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 18%;
+ text-align: center;
+ pointer-events: none;
}
-.amp-knob.ready { animation: amp-in 0.35s ease both; }
-/* After eleven: the knob takes its bow — fades out on the still-solid cover
- BEFORE the chat is revealed. (Declared after .ready so it wins.) */
-.amp-knob.out { animation: amp-out 0.32s ease both; }
-@keyframes amp-in {
- from { opacity: 0; }
- to { opacity: 1; }
+.boot-avatar {
+ position: relative;
+ width: 88px;
+ height: 88px;
+ margin: 0 auto 16px;
+ border-radius: 22px;
+ overflow: hidden;
+ background: linear-gradient(160deg, #6b6cf0, #4a48c8);
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
}
-@keyframes amp-out {
- from { opacity: 1; }
- to { opacity: 0; }
+.boot-avatar-letter {
+ display: block;
+ color: #fff;
+ font-size: 40px;
+ font-weight: 600;
+ line-height: 88px;
}
-.amp-fill {
+.boot-avatar img {
position: absolute;
- inset: 14px;
- border-radius: 50%;
- background: conic-gradient(from 210deg, var(--accent) var(--sweep, 0deg), color-mix(in srgb, var(--border) 55%, transparent) var(--sweep, 0deg) 300deg, transparent 300deg);
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.boot-name {
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ color: var(--ink);
}
-.amp-body {
+/* A quiet spinner dead centre — the app's own, at a zen 2.4s/rev. */
+.boot-spin {
position: absolute;
- inset: 24px;
+ left: 50%;
+ top: 50%;
+ width: 26px;
+ height: 26px;
+ margin: -13px 0 0 -13px;
border-radius: 50%;
- background: var(--panel);
- border: 1px solid var(--border);
- box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.18);
+ border: 3px solid color-mix(in srgb, var(--ink) 14%, transparent);
+ border-top-color: var(--accent);
+ animation: spin 2.4s linear infinite;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+}
+@media (prefers-reduced-motion: reduce) {
+ .boot-spin { animation: none; }
}
-.amp-pointer {
+/* The quotes: what a member actually reads while the green drifts. A quiet
+ serif, so they read as words from somewhere else, not UI. */
+.boot-quote {
position: absolute;
- left: calc(50% - 1.5px);
- top: 2px;
- width: 3px;
- height: 11px;
- border-radius: 2px;
- background: var(--ink);
+ left: 0;
+ right: 0;
+ top: 66%;
+ padding: 0 28px;
+ text-align: center;
+ pointer-events: none;
+}
+.boot-quote-text {
+ font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
+ font-style: italic;
+ font-size: 21px;
+ line-height: 1.55;
+ color: var(--ink);
+ max-width: 38ch;
+ margin: 0 auto 12px;
+ text-wrap: balance;
+}
+.boot-quote-attr {
+ font-size: 10px;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--muted);
}
-.amp-num {
+/* The one clickable thing on the cover — where all this came from. */
+.boot-goesto {
position: absolute;
- left: 50%;
- top: 50%;
- font-size: 11px;
- font-weight: 700;
+ left: 0;
+ right: 0;
+ bottom: 16px;
+ text-align: center;
+ font-size: 12px;
+ letter-spacing: 0.04em;
color: var(--muted);
- transform: translate(-50%, -50%) rotate(var(--a)) translateY(-41px) rotate(calc(var(--a) * -1));
+ text-decoration: none;
}
-/* Hitting eleven: the 11 lights up, a breath, and the whole cover fades fast
- (hideLoading's fade — no theatrics). */
-.amp-knob.pop .amp-num[data-n="11"] { color: var(--accent); }
+.boot-goesto b { font-weight: 600; color: var(--accent); }
+.boot-goesto:hover { color: var(--ink); }
/* The join page's card yields entirely to the full-screen loader while a claim
is in flight (see showLoader/showCard in join.js). */