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());