From 55bbd3e1b3d28f8d6aef84f65569cf676d619d0b Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 10 Aug 2026 16:27:56 -0400 Subject: [PATCH] feat: rosette empty state on a new conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports on a rim, relays on an inner ring, an edge from every port to every relay — the shape of the thing the app does, drawn in the same bayer dither as the context gauge and the reconnect sweep. Several streaks route across it at once rather than the mock's single one; a click sends another. Visibility is CSS plus one flag: #thread being empty isn't enough on its own, since it is also empty while an existing conversation's history is in flight, which flashed the drawing on every open. .loaded says we know why it's empty. The loop parks whenever nothing moves, sleeps on a hidden tab, and stops dead while the canvas has no box — which is its whole life once a conversation has messages. Streaks carry a bounding box so a burst of them costs comparisons rather than distance fields. --- src/client/app.css | 30 ++++ src/client/app.js | 10 ++ src/client/dither.js | 4 +- src/client/index.html | 5 + src/client/rosette.js | 312 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/client/rosette.js 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); + }, + }; +} -- 2.51.2