From d36ad8ed7eb940fa6654dc343bd98b610f0e0093 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 16:05:03 -0700 Subject: [PATCH] chat: handle colors reach every author, not just the ones online The renderer only asked the handle-colors API for authors already loaded at boot or currently in the presence strip, so laklok's history (which lands over the socket after boot) painted every handle in the plain theme color. It also re-asked for uncolored online handles every frame, and the lib's live "handle:colors" branch referenced a cache that only exists in the renderer, so a color change threw instead of recoloring. Now one ask per handle (in-flight deduped, "none" cached as null), asked when the room's history lands and for each new message, with the author's cached lines dropped so they recolor when an answer arrives. The lib passes the parsed live update through to the renderer's cache. tests/browser/laklok-handle-colors.test.mjs drives the real chat-clock room in Chrome: every author's cached answer matches the API, the ask counter sits still while idle, and per-character colors are read off a screenshot of the painted handle (seeded through a ?test=1-only hook, since no author in the room has colors set today). --- package.json | 2 + .../public/aesthetic.computer/disks/chat.mjs | 188 ++++++++++++++-- system/public/aesthetic.computer/lib/chat.mjs | 5 +- tests/browser/laklok-handle-colors.test.mjs | 209 ++++++++++++++++++ 4 files changed, 378 insertions(+), 26 deletions(-) create mode 100644 tests/browser/laklok-handle-colors.test.mjs diff --git a/package.json b/package.json index daae7f5d81..64b894d186 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "test:browser": "node tests/browser/prompt-search.test.mjs", "test:browser:headed": "AC_HEADED=1 AC_SLOWMO=60 node tests/browser/prompt-search.test.mjs", "test:browser:local": "AC_TEST_URL=https://localhost:8888 node tests/browser/prompt-search.test.mjs", + "test:laklok:colors": "node tests/browser/laklok-handle-colors.test.mjs", + "test:laklok:colors:local": "AC_TEST_URL=https://localhost:8888 node tests/browser/laklok-handle-colors.test.mjs", "xbox:test:mac": "sh xbox/tools/mac-test-app.sh", "xbox:play": "node xbox/tools/serve-live.mjs", "oskiewar:deploy": "node xbox/tools/oskiewar-release.mjs deploy", diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 3971dc6220..5914ff00fa 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -60,8 +60,19 @@ function isChatColorCode(code) { } // ๐ŸŽจ Handle Colors System -// Cache for handle colors: Map> +// Cache for handle colors: Map | null>. A `null` +// entry means "asked, none set" โ€” cached too, so a plain handle doesn't get +// re-asked every frame from the presence strip. const handleColorsCache = new Map(); +const handleColorsInFlight = new Map(); // handle โ†’ Promise, one ask per handle +let handleColorsFetches = 0; // count of asks made (the e2e test reads this) + +// ๐Ÿงช Test hook โ€” a BroadcastChannel the browser e2e listens on +// (tests/browser/laklok-handle-colors.test.mjs). Installed only when the +// piece booted with `?test=1` or in debug; silent otherwise. +let chatTestChannel = null; +let chatTestFrame = 0; +let chatClient = null; // the room this piece is showing (set in boot) // Convert a handle to colored text using \color\ syntax function colorizeHandle(handle, colors) { @@ -81,29 +92,98 @@ function colorizeHandle(handle, colors) { return result; } -// Fetch handle colors from API -async function fetchHandleColors(handle, api) { - // Remove @ if present - const cleanHandle = handle.startsWith("@") ? handle.slice(1) : handle; +// Fetch handle colors from the API โ€” once per handle. A miss (no colors set) +// is remembered as `null`; a network failure is not, so it can be retried. +// When colors do arrive the author's lines are relaid so they recolor. +async function fetchHandleColors(handle) { + const cleanHandle = (handle || "").startsWith("@") ? handle.slice(1) : handle; + if (!cleanHandle || cleanHandle === "log") return null; - if (handleColorsCache.has(cleanHandle)) { - return handleColorsCache.get(cleanHandle); - } + if (handleColorsCache.has(cleanHandle)) return handleColorsCache.get(cleanHandle); + if (handleColorsInFlight.has(cleanHandle)) return handleColorsInFlight.get(cleanHandle); - try { - const response = await fetch(`/.netlify/functions/handle-colors?handle=${encodeURIComponent(cleanHandle)}`); - if (response.ok) { - const data = await response.json(); - if (data.colors) { - handleColorsCache.set(cleanHandle, data.colors); - return data.colors; + const ask = (async () => { + try { + handleColorsFetches += 1; + // The functions path, not /api/ โ€” `netlify dev` only serves the former. + const response = await fetch(`/.netlify/functions/handle-colors?handle=${encodeURIComponent(cleanHandle)}`); + if (response.ok) { + const data = await response.json(); + const colors = Array.isArray(data.colors) && data.colors.length > 0 ? data.colors : null; + handleColorsCache.set(cleanHandle, colors); + if (colors) recolorHandle(cleanHandle); + return colors; } + } catch (error) { + console.warn(`Failed to fetch colors for @${cleanHandle}:`, error); + handleColorsCache.set(cleanHandle, null); // don't re-ask every frame; boot clears + } finally { + handleColorsInFlight.delete(cleanHandle); } - } catch (error) { - console.warn(`Failed to fetch colors for @${cleanHandle}:`, error); - } + return null; + })(); + handleColorsInFlight.set(cleanHandle, ask); + return ask; +} - return null; +// Drop the cached color lines of every message this handle wrote and ask +// for a relayout, so the next paint draws the handle in its colors. +function recolorHandle(cleanHandle) { + const target = cleanHandle.toLowerCase(); + (chatClient?.messages || []).forEach((m) => { + const from = m.from?.startsWith("@") ? m.from.slice(1) : m.from; + if (from?.toLowerCase() === target) { + delete m._colorLineCache; + delete m._colorLineHoverKey; + } + }); + messagesNeedLayout = true; +} + +// ๐Ÿงช Snapshot for the e2e: which authors are cached (and with how many +// colors), where each visible author handle was painted and whether it went +// out in custom colors, plus the presence strip. +function chatTestSnapshot() { + const messages = chatClient?.messages || []; + const authors = {}; + messages.forEach((m) => { + if (!m.from || m.from === "log") return; + const h = m.from.startsWith("@") ? m.from.slice(1) : m.from; + if (authors[h]) return; + authors[h] = { + cached: handleColorsCache.has(h), + colors: handleColorsCache.has(h) ? (handleColorsCache.get(h)?.length ?? null) : undefined, + }; + }); + return { + ready: true, + connecting: !!chatClient?.connecting, + messageCount: messages.length, + fetches: handleColorsFetches, + authors, + presence: chatClient?.onlineHandles || [], + visible: messages + .filter((m) => m._handleBox) + .map((m) => ({ from: m.from, ...m._handleBox })), + }; +} + +function installChatTestHook({ debug, query }) { + chatTestChannel?.close(); + chatTestChannel = null; + if (!debug && !query?.test) return; + if (typeof BroadcastChannel === "undefined") return; + chatTestChannel = new BroadcastChannel("ac-chat-test"); + chatTestChannel.onmessage = ({ data }) => { + // The test seeds colors for an author it saw in the room, so the paint + // path is proven without touching anyone's real colors. + if (data?.type === "seed-colors" && data.handle && Array.isArray(data.colors)) { + const h = data.handle.startsWith("@") ? data.handle.slice(1) : data.handle; + handleColorsCache.set(h, data.colors); + recolorHandle(h); + } + if (data?.type === "snapshot") chatTestChannel?.postMessage(chatTestSnapshot()); + }; } // ๐Ÿ”ค Chat Font System @@ -732,12 +812,16 @@ async function boot( params, hud, dom, + debug, + query, }, otherChat, options, ) { // Clear handle colors cache on each boot so edits are picked up. handleColorsCache.clear(); + handleColorsInFlight.clear(); + handleColorsFetches = 0; editingMessage = null; // A pending re-edit never survives a piece switch. chatMaxChars = options?.maxChars || 128; send({ type: "keyboard:set-max-chars", content: chatMaxChars }); @@ -763,7 +847,9 @@ async function boot( rowHeight = typeface.blockHeight + 1; const client = otherChat || chat; - + chatClient = client; + installChatTestHook({ debug, query }); + // Store typeface name from options if provided if (options?.typeface) { inputTypefaceName = options.typeface; @@ -811,9 +897,11 @@ async function boot( } }); - // Fetch colors for all unique handles (async, non-blocking) + // Fetch colors for all unique handles (async, non-blocking). The room's + // history usually lands after boot, so the paint loop asks again for any + // author it meets later โ€” this only warms what is already here. uniqueHandles.forEach(handle => { - fetchHandleColors(handle, api).catch(err => { + fetchHandleColors(handle).catch(err => { console.warn(`Failed to prefetch colors for @${handle}:`, err); }); }); @@ -871,6 +959,9 @@ async function boot( chatMaxChars = advertised; send({ type: "keyboard:set-max-chars", content: chatMaxChars }); } + // ๐ŸŽจ The room's history is here now โ€” ask once per author, on- or + // off-screen, so no handle is met before its colors are known. + client.messages.forEach((m) => fetchHandleColors(m.from).catch(() => {})); messagesNeedLayout = true; return; } @@ -893,10 +984,19 @@ async function boot( if (type === "message") { const msg = content; // Pre-transformed and stored. sound.play(messageSfx); + fetchHandleColors(msg?.from).catch(() => {}); // a new voice: ask once // delete store["chat:scroll"]; // Reset scroll on new message? // return; } + // ๐ŸŽจ Someone re-dyed their handle โ€” the lib parsed it; the cache lives here. + if (type === "handle:colors" && content?.handle) { + const h = content.handle.startsWith("@") ? content.handle.slice(1) : content.handle; + const colors = Array.isArray(content.colors) && content.colors.length > 0 ? content.colors : null; + handleColorsCache.set(h, colors); + recolorHandle(h); + } + if (extra?.layoutChanged) messagesNeedLayout = true; // console.log("๐ŸŒ  Message received:", id, type, content); }; @@ -1289,6 +1389,9 @@ function paint( // console.log(client.messages.length); + // ๐Ÿงช Boxes are re-noted each frame; anything the loop skips is off-screen. + if (chatTestChannel) client.messages.forEach((m) => (m._handleBox = null)); + for (let i = client.messages.length - 1; i >= 0; i--) { const message = client.messages[i]; @@ -1309,6 +1412,27 @@ function paint( const layout = message.layout; const y = layout.y; + // ๐ŸŽจ Ask once for this author's colors (cached, including "none"), and + // note where the handle lands this frame so the e2e can read the pixels. + if (message.from && message.from !== "log") { + if (!handleColorsCache.has(message.from.replace(/^@/, ""))) { + fetchHandleColors(message.from).catch(() => {}); + } + if (chatTestChannel) { + const inView = + y >= effectiveTopMargin && y + msgRowHeight <= screen.height - bottomMargin; + message._handleBox = inView + ? { + x, + y, + w: text.width(message.from, msgTypefaceName), + h: msgRowHeight, + colored: !!message._handleColored, + } + : null; + } + } + // ๐Ÿชง Paint the message // ๐ŸŽฏ Draw hover background if this message is being hovered @@ -1493,8 +1617,10 @@ function paint( perCharText += `\\${col.r},${col.g},${col.b}\\${escapeColorCodes(char)}\\${textColorStr}\\`; } customColorCodedText = perCharText; + message._handleColored = true; } else { color = theme.handle; + message._handleColored = false; } } } else if (element.type === "ytlink") { @@ -2151,6 +2277,14 @@ function paint( needsPaint(); } + // ๐Ÿงช Tell the e2e what got painted (every few frames, only when hooked). + if (chatTestChannel && ++chatTestFrame % 6 === 0) { + chatTestChannel.postMessage({ + ...chatTestSnapshot(), + screen: { w: screen.width, h: screen.height }, + }); + } + unmask(); // ๐Ÿ“œ Scroll bar โ€” only render when content actually exceeds the viewport. @@ -2588,7 +2722,7 @@ function paint( } } } else { - fetchHandleColors(cleanHandle, api).catch(() => {}); + fetchHandleColors(cleanHandle).catch(() => {}); ink(0, 0, 0, 180).write(h, { x: handleX + 1, top: presenceY + 1, @@ -5447,7 +5581,13 @@ function computeMessagesLayout({ screen, text, typeface }, chat, defaultTypeface for (let i = chat.messages.length - 1; i >= 0; i--) { const msg = chat.messages[i]; - + + // ๐ŸŽจ Ask once for every author's colors (cached, "none" included) so a + // scroll never surfaces a handle that hasn't been asked about yet. + if (msg.from && msg.from !== "log" && !handleColorsCache.has(msg.from.replace(/^@/, ""))) { + fetchHandleColors(msg.from).catch(() => {}); + } + // ๐Ÿ”ค Use per-message font settings (computed in computeMessagesHeight) // null typeface means "system default", not user's current selection const msgTypefaceName = msg.computedTypefaceName; // null = system default diff --git a/system/public/aesthetic.computer/lib/chat.mjs b/system/public/aesthetic.computer/lib/chat.mjs index 2d4d0e2338..3b157462d4 100644 --- a/system/public/aesthetic.computer/lib/chat.mjs +++ b/system/public/aesthetic.computer/lib/chat.mjs @@ -215,8 +215,9 @@ export class Chat { const msg = JSON.parse(content); console.log("๐ŸŽจ Handle colors update received:", msg.handle); const cleanHandle = msg.handle.startsWith("@") ? msg.handle.slice(1) : msg.handle; - handleColorsCache.set(cleanHandle, msg.colors); - // Re-colorize all messages from this handle. + // The color cache lives in the renderer (disks/chat.mjs), which + // receives the parsed update below and recolors that author. + content = msg; this.system.messages.forEach((message) => { const msgHandle = message.from?.startsWith("@") ? message.from.slice(1) : message.from; if (msgHandle?.toLowerCase() === cleanHandle.toLowerCase()) { diff --git a/tests/browser/laklok-handle-colors.test.mjs b/tests/browser/laklok-handle-colors.test.mjs new file mode 100644 index 0000000000..6a3146a791 --- /dev/null +++ b/tests/browser/laklok-handle-colors.test.mjs @@ -0,0 +1,209 @@ +// laklok-handle-colors.test, 2026.09.15 +// End-to-end proof that per-character handle colors reach the laklok +// (laer-klokken) room: every author in the room gets asked about once, the +// answer matches the handle-colors API, and a colored handle is painted in +// its own colors โ€” read off a screenshot of the running page, not a flag. +// +// npm run test:laklok:colors # headless, production +// npm run test:laklok:colors:local # against a local `npm run site` +// AC_HEADED=1 npm run test:laklok:colors +// +// The piece publishes its state on the "ac-chat-test" BroadcastChannel when +// booted with `?test=1` (disks/chat.mjs, installChatTestHook). A target +// without that hook fails the first scenario, which is the point. +// +// Puppeteer needs a Chrome; with none downloaded, point it at the installed +// one: PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". + +import { PNG } from "pngjs"; +import { ACSession, CONFIG, scenario, report } from "./ac-harness.mjs"; + +const base = new URL(CONFIG.baseURL); +const isLocal = base.hostname === "localhost" || base.hostname === "127.0.0.1"; +// A local `npm run site` is self-signed; Node's fetch must accept it too. +if (isLocal) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +const ac = await ACSession.open(); + +// The harness flips acDEBUG on so prompt pieces surface their hooks โ€” but +// in debug the chat lib dials a local chat-clock (localhost:8085). This +// test wants the real room, so debug goes off and the hook rides ?test=1. +await ac.page.evaluateOnNewDocument(() => { + window.acDEBUG = false; + let snapshot = null; + const channel = new BroadcastChannel("ac-chat-test"); + channel.onmessage = ({ data }) => { + if (data?.ready) snapshot = data; + }; + window.__acChatTest = () => snapshot; + window.__acChatSeed = (handle, colors) => + channel.postMessage({ type: "seed-colors", handle, colors }); +}); + +const state = () => ac.page.evaluate(() => window.__acChatTest?.() || null); + +async function waitFor(pred, { timeout = 30000, every = 250, label = "condition" } = {}) { + const t0 = Date.now(); + let last = null; + while (Date.now() - t0 < timeout) { + last = await state(); + if (last && pred(last)) return last; + await ac.wait(every); + } + throw new Error(`timed out waiting for ${label}; last state: ${JSON.stringify(last)?.slice(0, 600)}`); +} + +// Distinct rgb triples the page actually shows inside a box given in piece +// pixels. The runtime scales its canvas to the viewport, so the box is mapped +// through the canvas' on-page rect and cut out of a real screenshot. +async function paintedColors(box, screen) { + const rect = await ac.page.evaluate(() => { + const r = document.querySelector("#aesthetic-computer canvas").getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + const sx = rect.w / screen.w; + const sy = rect.h / screen.h; + const clip = { + x: rect.x + box.x * sx, + y: rect.y + box.y * sy, + width: Math.max(1, box.w * sx), + height: Math.max(1, box.h * sy), + }; + const bytes = await ac.page.screenshot({ clip, encoding: "binary" }); + const png = PNG.sync.read(Buffer.from(bytes)); + const seen = new Set(); + for (let i = 0; i < png.data.length; i += 4) { + seen.add(`${png.data[i]},${png.data[i + 1]},${png.data[i + 2]}`); + } + return { seen: [...seen], clip }; +} + +const rgb = (c) => `${c.r},${c.g},${c.b}`; +const clean = (h) => (h.startsWith("@") ? h.slice(1) : h); + +async function apiColors(handle) { + // The functions path answers on both lith and `netlify dev`; /api/ only on lith. + const res = await fetch( + `${CONFIG.baseURL}/.netlify/functions/handle-colors?handle=${encodeURIComponent(clean(handle))}`, + ); + if (!res.ok) throw new Error(`handle-colors ${res.status} for @${handle}`); + const { colors } = await res.json(); + return Array.isArray(colors) && colors.length > 0 ? colors : null; +} + +let room = null; + +try { + await scenario("laklok boots and the room fills", async (expect) => { + await ac.boot("laklok?test=1"); + room = await waitFor((s) => s.messageCount > 0, { + label: "chat-clock history (is the piece hooked, and the room reachable?)", + }); + await ac.shot("laklok-colors/01-room"); + expect(room.messageCount > 0, `room has messages (${room.messageCount})`); + expect(Object.keys(room.authors).length > 0, `room has authors (${Object.keys(room.authors).length})`); + }); + + await scenario("every author is asked once and the cache matches the API", async (expect) => { + // `netlify dev` answers each function call slowly (fresh DB connect), so + // a room with a couple dozen authors can take a while to settle. + room = await waitFor( + (s) => Object.values(s.authors).every((a) => a.cached), + { timeout: 90000, label: "colors resolved for every author" }, + ); + const authors = Object.keys(room.authors); + const expected = {}; + for (const h of authors) expected[h] = await apiColors(h); + + let mismatches = 0; + for (const h of authors) { + const want = expected[h] ? expected[h].length : null; + const got = room.authors[h].colors; + if (want !== got) { + mismatches++; + console.log(` โœ— @${h}: api says ${want} colors, piece cached ${got}`); + } + } + expect(mismatches === 0, `cached colors match the handle-colors API for ${authors.length} authors`); + + const colored = authors.filter((h) => expected[h]); + console.log(` โ„น๏ธ authors with colors set: ${colored.length ? colored.map((h) => "@" + h).join(" ") : "none"}`); + + // No re-asking: the fetch counter must sit still while the room idles. + const before = room.fetches; + await ac.wait(3000); + const after = (await state()).fetches; + expect( + after === before, + `no refetch storm while idle (${before} โ†’ ${after} asks for ${authors.length} authors + ${room.presence.length} online)`, + ); + expect( + before <= authors.length + room.presence.length + 2, + `one ask per handle at most (${before})`, + ); + }); + + await scenario("a real colored author paints in their colors", async (expect) => { + const st = await state(); + const visibleColored = []; + for (const v of st.visible) { + const colors = await apiColors(v.from); + if (colors) visibleColored.push({ ...v, colors }); + } + if (visibleColored.length === 0) { + console.log(" โ„น๏ธ no visible author has custom colors right now โ€” seeded scenario covers the paint path"); + expect(true, "skipped (no colored author on screen)"); + return; + } + const v = visibleColored[0]; + expect(v.colored, `@${clean(v.from)} was painted through the custom-color path`); + const distinct = [...new Set(v.colors.map(rgb))]; + const { seen, clip } = await paintedColors(v, st.screen); + const hits = distinct.filter((c) => seen.includes(c)).length; + await ac.shot("laklok-colors/02-real-colors"); + expect( + hits >= Math.min(3, distinct.length), + `page shows @${clean(v.from)}'s colors (${hits}/${distinct.length} at ${JSON.stringify(clip)})`, + ); + }); + + await scenario("seeded colors paint per character (deterministic)", async (expect) => { + let st = await state(); + // The lowest visible message sits clear of the header; take its author. + const target = [...st.visible].sort((a, b) => b.y - a.y)[0]; + expect(!!target, "a visible message to seed"); + if (!target) return; + + const handle = clean(target.from); + // Loud, unmistakable colors โ€” one per character of "@handle". + const palette = [ + [250, 10, 20], [10, 250, 20], [20, 10, 250], [250, 250, 10], + [250, 10, 250], [10, 250, 250], [250, 130, 10], [130, 10, 250], + ]; + const colors = [...("@" + handle)].map((_, i) => { + const [r, g, b] = palette[i % palette.length]; + return { r, g, b }; + }); + await ac.page.evaluate((h, c) => window.__acChatSeed(h, c), handle, colors); + + st = await waitFor( + (s) => s.visible.some((v) => clean(v.from) === handle && v.colored), + { timeout: 10000, label: `@${handle} to repaint in seeded colors` }, + ); + const box = st.visible.find((v) => clean(v.from) === handle); + await ac.wait(400); // one more paint so the page holds the new lines + await ac.shot("laklok-colors/03-seeded"); + + const distinct = [...new Set(colors.map(rgb))]; + const { seen, clip } = await paintedColors(box, st.screen); + const hits = distinct.filter((c) => seen.includes(c)); + expect( + hits.length >= Math.min(3, distinct.length), + `page shows the seeded per-character colors (${hits.length}/${distinct.length} at ${JSON.stringify(clip)}; box ${JSON.stringify(box)})`, + ); + }); +} finally { + await ac.close(); +} + +process.exit(report()); -- 2.51.2