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>
7 files changed,
+882
−255
changelog/2026-07-08-boot-scene.md+5 −0package.json+1 −1test/bootscene.test.mjs+91 −0web/pages/index.html+19 −11web/static/bootscene.js+646 −0web/static/shell.js+21 −194web/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). */ |