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>
4 files changed,
+320
−12
home/web/pages/index.html+62 −4home/web/static/bootscene.js+239 −0home/web/static/home.css+1 −1home/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(); } |
| })(); |
| } |