eleven / messenger Public

home: smoke-and-motes loading scene + ambient background — no more Sign-in flash (#464)

A returning member refreshing home saw the Sign-in screen flash before the
spaces list decrypted. Now:

- A pre-paint gate in <head> spots a live session (home_token + home_dek in
  sessionStorage) and raises the loading cover from the very first frame —
  signed-out visitors never see it.
- The cover is a new home/web/static/bootscene.js: a trimmed sibling of the
  messenger's boot scene — the same smoke + rising dust motes, but no meter
  lights, no quotes, no punch. Eleven lockup + "The group chat of dreams"
  tagline over the haze; holds ~2s, then the chrome fades and the content
  eases in.
- The smoke + motes stay running as the page's ambient background for every
  state (dimmed to 40%; full during the loader splash). Light mode gets its
  own darker, denser smoke recipe so the haze actually reads on a white bg.
- Type lightened while in there: the Eleven wordmark 800→500, the section
  sub-headings (vault h2, create/archive titles, building h3, created-name)
  700/400.

Honours prefers-reduced-motion (a single still frame). Tuned live in-browser
with Paul against a local build, both themes.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8aeb003c89ffe4db971d81e0bb3c49b84284caab parent 4c30467
4 files changed, +320 −12
  • home/web/pages/index.html +62 −4
  • home/web/static/bootscene.js +239 −0
  • home/web/static/home.css +1 −1
  • home/web/static/home.js +18 −7
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index d912d65..1eb115f 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -24,7 +24,7 @@
display font. */
.brand-word {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
- font-weight: 800; font-size: 26px; letter-spacing: -0.02em;
+ font-weight: 500; font-size: 26px; letter-spacing: -0.02em;
}
#signin { width: 100%; }
/* The after-failure panel: a quiet inset, left-aligned so the longer copy
@@ -55,7 +55,7 @@
.screen { animation: screen-in .18s ease; }
@media (prefers-reduced-motion: reduce) { .screen { animation: none; } }
.vault-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: 6px; }
- #vault h2 { font-size: 19px; font-weight: 700; margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
+ #vault h2 { font-size: 19px; font-weight: 400; margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
/* The + that starts a new space, inline with the heading. */
.icon-add {
flex: none; width: 30px; height: 30px; border-radius: 50%;
@@ -87,7 +87,7 @@
.icon-back:hover { filter: brightness(0.97); }
.icon-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.create-title {
- font-family: var(--font-display); font-size: 23px; font-weight: 700;
+ font-family: var(--font-display); font-size: 23px; font-weight: 400;
line-height: 1.2; letter-spacing: -0.015em; margin: 20px 0 4px;
}
.create-sub { color: var(--muted); font-size: 14px; margin: 0 0 24px; }
@@ -165,7 +165,7 @@
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
- #create-building h3 { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
+ #create-building h3 { font-size: 17px; font-weight: 400; margin: 0 0 4px; }
#create-building p { color: var(--muted); font-size: 14px; margin: 0; }
/* Reduced motion: no strobe, no spinner spin, no transitions — states still
change, instantly. (Dust, confetti, shake, and VU flicker are skipped in
@@ -307,12 +307,70 @@
}
ul.servers li.dragging .drag-handle { opacity: 1; color: var(--ink); }
@media (prefers-reduced-motion: reduce) { .drag-handle { transition: none; } }
+
+ /* Ambient smoke + motes (bootscene.js), pinned behind ALL content (z-index -1,
+ above the page bg but under the card/text). Started on load for everyone and
+ left running — the page's living backdrop. Dimmed as a background; fuller
+ during the loader splash (html.booting), so it recedes as the content lands. */
+ .boot-canvas {
+ position: fixed; inset: 0; width: 100%; height: 100%;
+ z-index: -1; pointer-events: none;
+ opacity: .4; transition: opacity .6s ease;
+ }
+ html.booting .boot-canvas { opacity: 1; }
+ /* The returning-member loading chrome: the Eleven lockup + tagline centred over
+ the smoke while the spaces list decrypts. Shown only when html.booting (a
+ live session, set pre-paint), which also hides the content beneath so the
+ Sign-in screen never flashes. On finish bootscene.js fades this out
+ (.cover-out) and drops html.booting — the card fades in, the smoke stays. */
+ .app-loading {
+ position: fixed; inset: 0; z-index: 2;
+ display: none; place-items: center; align-content: center;
+ transition: opacity .5s ease;
+ }
+ html.booting .app-loading { display: grid; }
+ /* Hide the content (card + its outside-the-card reassurance) beneath the loader
+ so nothing flashes; both fade back in when booting drops. */
+ html.booting main.card, html.booting .reassure { opacity: 0; }
+ main.card, .reassure { transition: opacity .5s ease; }
+ .app-loading.cover-out { opacity: 0; pointer-events: none; }
+ .boot-brand { display: flex; flex-direction: column; align-items: center; gap: 10px; }
+ .boot-goesto {
+ position: absolute; left: 0; right: 0; bottom: 18px; margin: 0;
+ text-align: center; color: var(--muted); font-size: 13px; letter-spacing: .02em;
+ }
</style>
+<script>
+ // Pre-paint gate: if this browser holds a live session, show the loading
+ // cover from the very first frame so a refresh doesn't flash the Sign-in
+ // screen before the spaces list decrypts. Signed-out visitors skip it.
+ try {
+ if (sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek"))
+ document.documentElement.classList.add("booting");
+ } catch (e) {}
+</script>
</head>
<body class="landing">
+<!-- Persistent ambient background (bootscene.js): soft smoke + rising dust motes
+ drifting behind everything. Started on load for all visitors and left running
+ — the returning-member loading chrome (below) fades over it, then lifts to
+ leave the smoke as the page's living backdrop. -->
+<canvas class="boot-canvas" id="boot-canvas" aria-hidden="true"></canvas>
<!-- Stage for the create-fader's dust + confetti (fader.js) — full-viewport,
behind the card, drawn only while the create screen is live. -->
<canvas id="stage-dust" aria-hidden="true"></canvas>
+<!-- The returning-member loading chrome: the Eleven lockup + tagline over the
+ smoke while the spaces list decrypts, so a refresh doesn't flash the Sign-in
+ screen first. Shown only when html.booting (a live session — see the
+ pre-paint gate above); it fades out once the list is ready, and the smoke
+ behind it stays. -->
+<div id="app-loading" class="app-loading" aria-hidden="true">
+ <div class="boot-brand">
+ <img class="brand-mark" src="/static/icon-512.png" width="64" height="64" alt="Eleven" />
+ <div class="brand-word">Eleven</div>
+ </div>
+ <p class="boot-goesto">The group chat of dreams</p>
+</div>
<main class="card">
<div class="brand">
<img class="brand-mark" src="/static/icon-512.png" width="64" height="64" alt="Eleven" />
diff --git a/home/web/static/bootscene.js b/home/web/static/bootscene.js
new file mode 100644
index 0000000..5efbe5c
--- /dev/null
+++ b/home/web/static/bootscene.js
@@ -0,0 +1,239 @@
+// bootscene.js — the home loading cover's scene: soft smoke and rising dust
+// motes drifting in a theme-matched haze. A trimmed sibling of the messenger's
+// boot scene (web/static/bootscene.js): the SAME smoke + motes, but none of the
+// "lights" (no under-lit 11-segment meter), no quotes, and no punch-to-eleven.
+//
+// Home just needs a calm cover over the gap between "signed in on this browser"
+// and "your spaces list has decrypted + rendered", so a refresh doesn't flash
+// the Sign-in screen first. It's shown only for returning members (a live
+// session in sessionStorage) — a pre-paint gate in index.html adds html.booting
+// so the cover is up from the very first frame; signed-out visitors never see it.
+//
+// home.js drives it with two calls:
+// startHomeLoader() — reveal the cover + begin the drift
+// finishHomeLoader() — fade the cover away once the list (or Sign-in) is ready
+
+// Hold the loader at least this long before it fades — a deliberate beat on the
+// scene, so even an instant load gets ~2s of smoke rather than a strobe.
+const MIN_SHOW = 2000;
+
+let scene = null; // the live scene, if startHomeLoader ran
+
+// Soft cloud sprite: layered radial blobs, feathered, tinted once. (Identical to
+// the messenger scene's puff so the smoke reads the same.)
+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 startHomeLoader() {
+ const canvas = document.getElementById("boot-canvas");
+ if (!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 (theme +
+ // colour-scheme decide it). Dark room: white smoke, additive glints. Lit
+ // room: dark grey smoke + dust that occludes over the light bg.
+ 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, mote: [255, 255, 255], moteA: 0.42, puffMul: 1 }
+ : { comp: "source-over", neutral: [26, 28, 38], neutralA: 0.65, mote: [24, 26, 36], moteA: 0.65, puffMul: 1.7 };
+
+ const shapes = Array.from({ length: 6 }, () => makePuffShape(256));
+ const sprites = shapes.map((s) => tinted(s, room.neutral));
+
+ const S = {
+ canvas, g, room, sprites, reduced,
+ W: 0, H: 0, puffs: [], motes: [], grain: null, raf: 0, t0: performance.now(),
+ };
+ scene = S;
+ sizeScene(S);
+ seedScene(S);
+ makeGrain(S);
+ S.onResize = () => { sizeScene(S); seedScene(S); };
+ addEventListener("resize", S.onResize);
+
+ if (reduced) { drawFrame(S, 0, performance.now()); return; } // a single considered still
+ let last = performance.now();
+ const tick = (now) => {
+ if (!S.canvas.isConnected || scene !== S) return; // finishHomeLoader removed/replaced it
+ const dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ drawFrame(S, dt, now);
+ S.raf = requestAnimationFrame(tick);
+ };
+ S.raf = requestAnimationFrame(tick);
+}
+
+// finishHomeLoader lifts the loading chrome (honouring a minimum on-screen time):
+// it drops html.booting so the content fades in, and fades out the Eleven lockup
+// + tagline — leaving the smoke canvas running as the page's ambient background.
+export function finishHomeLoader() {
+ const chrome = document.getElementById("app-loading");
+ const elapsed = scene ? performance.now() - scene.t0 : MIN_SHOW;
+ setTimeout(() => {
+ document.documentElement.classList.remove("booting"); // reveal + fade the content in
+ if (chrome) {
+ chrome.classList.add("cover-out"); // fade the lockup + tagline out
+ setTimeout(() => chrome.remove(), 600);
+ }
+ // NB: the smoke canvas + its rAF are intentionally left running — the scene
+ // is the page's living backdrop now, not a cover to tear down.
+ }, Math.max(0, MIN_SHOW - elapsed));
+}
+
+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;
+ // Two counter-drifting layers hanging in the lower two thirds — the smoke
+ // floats, it doesn't fill the ceiling. Counts scale down on small screens.
+ const k = Math.max(0.5, Math.min(1, W / 1100));
+ const mul = S.room.puffMul || 1; // per-room smoke density (light needs more)
+ S.puffs = [];
+ for (const layer of [
+ { n: Math.round(22 * k * mul), s: [480, 860], v: [13, 26], a: 0.6, dir: 1, band: [0.3, 0.98] },
+ { n: Math.round(18 * k * mul), 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 drawSmoke(S, dt) {
+ const { g, W } = S;
+ g.save();
+ g.globalCompositeOperation = S.room.comp;
+ g.globalAlpha = 1;
+ 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;
+ g.save();
+ g.translate(p.x, y);
+ g.rotate(p.rot);
+ g.globalAlpha = p.a * S.room.neutralA; // no meter to light it — its own quiet presence
+ g.drawImage(S.sprites[p.spi], -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();
+ g.globalCompositeOperation = room.comp; // dark room: additive glints; lit room: over
+ 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 tw = 0.65 + 0.35 * Math.sin(now * 0.001 * m.tw + m.ph * 3);
+ const a = room.moteA * tw;
+ if (a < 0.015) continue;
+ g.globalAlpha = a;
+ g.fillStyle = `rgb(${room.mote[0]},${room.mote[1]},${room.mote[2]})`;
+ 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
+ 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();
+ }
+}
diff --git a/home/web/static/home.css b/home/web/static/home.css
index e8af88b..8ea38ac 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -140,7 +140,7 @@ input:focus-visible { outline: 2px solid var(--accent); border-color: transparen
display: grid; place-items: center; background: var(--ok); color: #fff;
}
.ok-check svg { width: 22px; height: 22px; }
-.created-name { font-family: var(--font-display); font-weight: 700; font-size: 17px; margin: 0 0 4px; }
+.created-name { font-family: var(--font-display); font-weight: 400; font-size: 17px; margin: 0 0 4px; }
a.btn-link {
display: block;
font: inherit;
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 908f4d4..b6f1889 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -24,6 +24,7 @@ import {
toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold,
} from "/static/serverlist.js";
import { mountFader } from "/static/fader.js";
+import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js";
// ---- session state ----------------------------------------------------------
// Session-only on purpose (the design doc's web default): closing the tab
@@ -985,18 +986,28 @@ apiJSON("/api/features")
})
.catch(() => { /* older home server without the endpoint: leave the + hidden */ });
+// The ambient smoke + motes background (bootscene.js) drifts behind every state
+// of the page, for everyone — started once, left running.
+startHomeLoader();
+
// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
-// sessionStorage, so the vault can reopen without a ceremony.
+// sessionStorage, so the vault can reopen without a ceremony. The loading chrome
+// (the Eleven lockup + tagline, already up pre-paint via html.booting) hides the
+// Sign-in flash until the list has decrypted; finishHomeLoader lifts it — fading
+// the content in over the smoke — whether we land on the vault or, if the session
+// went stale, back on Sign-in.
if (state.token && state.dek) {
(async () => {
try {
const got = await fetchBlob(state.token);
- if (!got) return;
- dekKey = await importDEK(state.dek);
- const opened = await openBlob(got.bytes, dekKey);
- vault = { obj: opened.obj, keys: opened.keys, version: got.version };
- setStatus("");
- showVault();
+ if (got) {
+ dekKey = await importDEK(state.dek);
+ const opened = await openBlob(got.bytes, dekKey);
+ vault = { obj: opened.obj, keys: opened.keys, version: got.version };
+ setStatus("");
+ showVault();
+ }
} catch { /* token or DEK went stale; the button still works */ }
+ finally { finishHomeLoader(); }
})();
}