diff --git a/src/client/app.css b/src/client/app.css index 1b168d3..b123f3c 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -948,6 +948,36 @@ body.selecting #selectBtn { position: relative; scrollbar-gutter: stable; } +/* The empty state, shown only for a conversation that really is empty. + `:empty` alone isn't that: #thread is also empty while an existing + conversation's history is still in flight, which flashed the drawing on every + open. `.loaded` is set once the history has settled, so the two conditions + together mean "no turns, and we know why". */ +.emptystate { + display: none; + position: absolute; + inset: 0; + place-items: center; + padding: 24px; + pointer-events: none; +} +.chatshell.loaded .thread:empty ~ .emptystate { + display: grid; +} +.rosette { + width: min(52vh, 60%, 420px); + aspect-ratio: 1; + display: grid; + place-items: center; + /* The overlay itself is click-through so the thread still scrolls; only the + drawing takes the pointer, because clicking it sends a message across. */ + pointer-events: auto; + cursor: pointer; +} +.rosette canvas { + display: block; +} + .thread { max-width: var(--measure); margin: 0 auto; diff --git a/src/client/app.js b/src/client/app.js index 1963c4e..d454d22 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -36,6 +36,7 @@ import { TOOL_ICON as ICON_TOOL, SEND_ICON as SEND, } from "./icons.js"; +import { mountRosette } from "./rosette.js"; import { createRouter } from "./router.js"; import { mountSidebar } from "./sidebar.js"; @@ -2307,6 +2308,7 @@ import { mountSidebar } from "./sidebar.js"; retryDelay = RETRY_MIN; retryCount = 0; hat.set("live"); + $("chatShell").classList.remove("loaded"); // until this one's history lands convId = id; // The open document belongs to the conversation being left, so it goes with // it rather than hanging over the next one. @@ -2338,6 +2340,10 @@ import { mountSidebar } from "./sidebar.js"; if (convId !== id) return; // switched conversations while loading var tail = await streamInto(res, id, null, true); // phase 1: newest turns at the bottom if (convId !== id) return; + // History has settled, so an empty thread now means an empty CONVERSATION + // rather than one still loading. Only then may the empty state show — the + // CSS can see that #thread has no turns, but not why. + $("chatShell").classList.add("loaded"); // No completed turn carried usage for this conversation (a new/empty chat, or // one whose last turn had none): the gauge is still showing the previous // conversation's value, so drain it to empty now. @@ -2717,6 +2723,10 @@ import { mountSidebar } from "./sidebar.js"; var conversations = []; var dialogs = mountDialogs(); mountPane(); + // The empty state. CSS decides when it's visible (see .emptystate); the + // rosette itself sleeps whenever it has no box to draw into, so mounting it + // unconditionally costs nothing in a conversation that already has messages. + mountRosette($("rosette")); // The section the router is currently showing ("chat" | "conversations" | // "projects" | "settings"). The sidebar's "New chat" highlight is a chat-view // concern, so it only lights up while we're actually on chat. diff --git a/src/client/dither.js b/src/client/dither.js index 4b522ad..2727327 100644 --- a/src/client/dither.js +++ b/src/client/dither.js @@ -7,14 +7,14 @@ * pattern stays chunky and legible instead of dissolving into a gradient. */ -const BAYER = [ +export const BAYER = [ [0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5], ]; -function smoothstep(a, b, x) { +export function smoothstep(a, b, x) { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); } diff --git a/src/client/index.html b/src/client/index.html index 26b2baa..716df67 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -70,6 +70,11 @@
+ +
diff --git a/src/client/rosette.js b/src/client/rosette.js new file mode 100644 index 0000000..03fd1ce --- /dev/null +++ b/src/client/rosette.js @@ -0,0 +1,312 @@ +/* + * The empty state: a dithered rosette with messages routing across it. + * + * Ports around a rim, a smaller ring of relays inside, and an edge from every + * port to every relay — so any port reaches any other through the middle. It is + * the shape of the thing the app does, drawn in the same bayer dither the + * context gauge and the reconnect sweep use, which is why it belongs here rather + * than being a decorative spinner. + * + * It costs nothing when nothing is happening. The loop parks itself the moment + * every streak has faded and breathing is off, sleeps on a hidden tab, and stops + * entirely while the canvas has no layout box — which is most of the time, since + * the empty state is only visible before the first message. + */ +import { BAYER, smoothstep } from "./dither.js"; + +/** CSS pixels per dither pixel. Chunky on purpose; it's the same grid as the bar. */ +const SCALE = 2; +/** How many streaks may be in flight at once. High enough that clicking a lot + * actually sends a lot; affordable because a row only ever tests the streaks + * that cross it (see render). */ +const MAX_STREAKS = 25; + +/** Minimum gap between click-spawned streaks. Fast clicking should feel + * generous, not dump the whole budget into one frame. */ +const CLICK_MS = 90; +/** Gap between streaks: rare enough to be a punctuation rather than a texture, + * and re-rolled each time so they never settle into a rhythm. */ +const SPAWN_MIN_MS = 6_000; +const SPAWN_MAX_MS = 10_000; + +/** + * The mesh at rest, and a streak at full brightness. + * + * Deliberately not `--accent`: the theme accent is a UI colour, sized to carry + * a button, and at the scale of the whole empty state it shouts. These are the + * quieter blues the drawing was designed around. + */ +const REST = [0x74, 0x89, 0xab]; +const LIT = [0x8f, 0xb4, 0xf0]; + +/** Squared distance from a point to a segment, plus how far along it landed. */ +function segDist2(px, py, a, b) { + const vx = b.x - a.x; + const vy = b.y - a.y; + const wx = px - a.x; + const wy = py - a.y; + const len = vx * vx + vy * vy; + let t = len ? (wx * vx + wy * vy) / len : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const qx = wx - vx * t; + const qy = wy - vy * t; + return { d2: qx * qx + qy * qy, t }; +} + +export function mountRosette(canvas, opts) { + const cfg = { + ports: 10, + mids: 5, + midR: 0.36, + lineR: 1.05, + ink: 1, + breathe: true, + ...(opts || {}), + }; + const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; + const ctx = canvas.getContext("2d"); + const off = document.createElement("canvas"); + const octx = off.getContext("2d"); + + let pw = 0; + let ph = 0; + let img = null; + let base = null; + let net = null; + /** Every streak in flight. Several at once is the whole point: one message + * crossing an idle machine reads as a demo; four reads as a system. */ + let streaks = []; + let breath = 0; + let raf = null; + let visible = !document.hidden; + /** Whether the canvas currently has a box to draw into. False for the whole + * life of a conversation that has messages — the empty state is display:none + * — and the loop must not run then, or the first message would leave a + * render loop burning behind it forever. */ + let live = false; + + function layout() { + const cx = pw / 2; + const cy = ph / 2; + const R = Math.min(pw, ph) * 0.45; + const rim = Array.from({ length: cfg.ports }, (_, i) => { + const a = -Math.PI / 2 + (i / cfg.ports) * Math.PI * 2; + return { x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R, r: 3.2 }; + }); + const mid = Array.from({ length: cfg.mids }, (_, i) => { + const a = -Math.PI / 2 + ((i + 0.5) / cfg.mids) * Math.PI * 2; + return { x: cx + Math.cos(a) * R * cfg.midR, y: cy + Math.sin(a) * R * cfg.midR, r: 3.9 }; + }); + const edges = []; + for (const p of rim) for (const m of mid) edges.push({ a: p, b: m }); + net = { rim, mid, edges, nodes: [...rim, ...mid] }; + } + + /** The still image: thin edges and hollow node rings, baked once per resize. */ + function bake() { + base = new Float32Array(pw * ph); + const lr = cfg.lineR; + for (let y = 0; y < ph; y++) { + for (let x = 0; x < pw; x++) { + let d = 0; + for (const e of net.edges) + d += Math.exp(-segDist2(x + 0.5, y + 0.5, e.a, e.b).d2 / (lr * lr)) * 0.62; + for (const n of net.nodes) { + const r = Math.hypot(x + 0.5 - n.x, y + 0.5 - n.y); + d = Math.max( + d, + smoothstep(n.r + 1.1, n.r - 0.2, r) * smoothstep(n.r * 0.3, n.r * 0.7, r), + ); + } + base[y * pw + x] = Math.min(1, d); + } + } + } + + function resize() { + const box = canvas.parentElement.getBoundingClientRect(); + if (!box.width || !box.height) return false; // hidden: nothing to size against + const side = Math.max(180, Math.min(box.width, box.height, 420)); + const dpr = Math.min(devicePixelRatio || 1, 2); + canvas.style.width = `${Math.round(side)}px`; + canvas.style.height = `${Math.round(side)}px`; + canvas.width = Math.round(side * dpr); + canvas.height = Math.round(side * dpr); + ctx.imageSmoothingEnabled = false; + pw = Math.round(side / SCALE); + ph = Math.round(side / SCALE); + off.width = pw; + off.height = ph; + img = octx.createImageData(pw, ph); + layout(); + bake(); + return true; + } + + /** + * Brightness contributed at one pixel by the streaks crossing this row. + * + * `live` is prefiltered per row by the caller, so a pixel never looks at a + * streak on the far side of the drawing. That row filter is what makes a + * couple of dozen streaks cost about what a couple used to. + */ + function streakAt(x, y, live) { + const lr = cfg.lineR; + let best = 0; + for (const s of live) { + if (x < s.x0 || x > s.x1) continue; // two compares, then skip + for (let si = 0; si < 2; si++) { + const seg = s.segs[si]; + const { d2, t } = segDist2(x + 0.5, y + 0.5, seg.a, seg.b); + // Reduced motion: no travelling head — the whole path lights and fades. + const lag = reduced ? s.phase * 0.6 : s.phase - (si + t) / 2; + if (lag < 0 || lag > 0.62) continue; + const tail = lag < 0.05 ? 1 : 1 - smoothstep(0.05, 0.62, lag); + best = Math.max(best, tail * Math.exp(-d2 / (lr * lr * 1.35))); + } + for (let i = 0; i < 3; i++) { + const n = s.nodes[i]; + const lag = reduced ? s.phase * 0.6 : s.phase - i * 0.5; + if (lag < 0 || lag > 0.34) continue; + const r = Math.hypot(x + 0.5 - n.x, y + 0.5 - n.y); + best = Math.max(best, (1 - smoothstep(0, 0.34, lag)) * smoothstep(n.r * 2.6, n.r * 0.6, r)); + } + } + return best; + } + + /** Send one message: a rim port, through a relay, to a different rim port. */ + function pulse() { + if (!net || !live || streaks.length >= MAX_STREAKS) return; + const pick = (arr) => arr[(Math.random() * arr.length) | 0]; + const a = pick(net.rim); + const m = pick(net.mid); + let b = pick(net.rim); + for (let guard = 0; b === a && guard < 24; guard++) b = pick(net.rim); + // A bounding box around everything this streak can ever light: the three + // nodes, widened by the segment glow and the node flash. Without it, every + // extra streak costs a full pass over every pixel, and a burst of clicks + // would trade the frame rate for the burst. + const pad = Math.max(cfg.lineR * 3, a.r * 2.6, m.r * 2.6, b.r * 2.6); + const xs = [a.x, m.x, b.x]; + const ys = [a.y, m.y, b.y]; + streaks.push({ + segs: [ + { a, b: m }, + { a: m, b }, + ], + nodes: [a, m, b], + phase: 0, + x0: Math.min(...xs) - pad, + x1: Math.max(...xs) + pad, + y0: Math.min(...ys) - pad, + y1: Math.max(...ys) + pad, + }); + wake(); + } + + function render() { + // Breathing: a slow swell in the resting mesh, deep enough to read as alive + // from across the room rather than only when you stare at it. + const gain = (cfg.breathe && !reduced ? 0.9 + Math.sin(breath) * 0.16 : 1) * cfg.ink; + const d = img.data; + const row = []; + for (let y = 0; y < ph; y++) { + const brow = BAYER[y & 3]; + // Which streaks reach this row at all. Computed once per row rather than + // per pixel, which is the difference between 25 streaks being affordable + // and being a slideshow. + row.length = 0; + for (const s of streaks) if (y >= s.y0 && y <= s.y1) row.push(s); + for (let x = 0; x < pw; x++) { + const i = y * pw + x; + const hl = row.length ? streakAt(x, y, row) : 0; + const v = Math.min(0.88, base[i] * gain + hl * 0.3); + const o = i * 4; + if (v <= (brow[x & 3] + 0.5) / 16) { + d[o + 3] = 0; + continue; + } + // A streak lifts the resting blue toward the brighter one. + const k = Math.min(1, hl * 1.25); + d[o] = Math.round(REST[0] + (LIT[0] - REST[0]) * k); + d[o + 1] = Math.round(REST[1] + (LIT[1] - REST[1]) * k); + d[o + 2] = Math.round(REST[2] + (LIT[2] - REST[2]) * k); + d[o + 3] = Math.round(34 + v * 52 + k * 88); + } + } + octx.putImageData(img, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(off, 0, 0, canvas.width, canvas.height); + } + + function frame() { + raf = null; + if (!visible || !live || !base) return; + if (cfg.breathe && !reduced) breath += 0.009; + for (const s of streaks) s.phase += 0.01; + streaks = streaks.filter((s) => s.phase <= 1.72); + render(); + // Park as soon as nothing is moving. With breathing off and no streaks in + // flight this draws one frame and then costs nothing at all. + if ((cfg.breathe && !reduced) || streaks.length) raf = requestAnimationFrame(frame); + } + function wake() { + if (!raf && visible && live && base) raf = requestAnimationFrame(frame); + } + + document.addEventListener("visibilitychange", () => { + visible = !document.hidden; + if (visible) wake(); + else if (raf) { + cancelAnimationFrame(raf); + raf = null; + } + }); + + // Re-bake when the box changes — and this is also how it starts, since the + // empty state is display:none until a conversation is actually empty. + if (window.ResizeObserver) { + new ResizeObserver(() => { + live = resize(); + if (live) wake(); + else if (raf) { + cancelAnimationFrame(raf); + raf = null; + streaks = []; // nothing in flight should survive being hidden + } + }).observe(canvas.parentElement); + } + live = resize(); + wake(); + + // Traffic. A fresh delay each time rather than a fixed interval, so the + // spacing never becomes a pattern you can anticipate. + let timer = null; + function schedule() { + timer = setTimeout( + () => { + if (!document.hidden) pulse(); + schedule(); + }, + SPAWN_MIN_MS + Math.random() * (SPAWN_MAX_MS - SPAWN_MIN_MS), + ); + } + schedule(); + // And on demand: the drawing is the one thing on an empty screen worth poking. + let lastClick = 0; + canvas.addEventListener("click", () => { + const now = performance.now(); + if (now - lastClick < CLICK_MS) return; + lastClick = now; + pulse(); + }); + + return { + pulse, + stop() { + clearTimeout(timer); + if (raf) cancelAnimationFrame(raf); + }, + }; +}