// Thin Ice: the page controller. Wires the lake, the agent loop and the // three ways to play (Watch, You're the model, Live) to the panels. import { createLake, SYSTEM_PROMPT, TOOLS, TASK, cellName } from "./world.js"; import { agentLoop } from "./loop.js"; import { scriptedTurn, resetScriptedIds } from "./scripted.js"; import LOOP_SOURCE from "./loop-source.js"; const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); const WATCH_SEEDS = [7, 6, 14, 22, 32, 8, 27, 49, 11, 4]; const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; const params = new URLSearchParams(location.search); class Cancelled extends Error {} const CONCEPTS = { tools: ["Tool use", "The model asks for a tool with a tool_use block. Your code runs it."], parallel: ["Parallel calls", "Independent tool calls can come in one turn. All results go back together."], errors: ["Error recovery", "A failed tool returns is_error: true, and the model adjusts."], history: ["Growing context", "Every request resends the whole conversation. That's the model's only memory."], stop: ["Stop reason", "stop_reason tells your loop whether to keep going."], }; const LINES = { request: "await callModel(", response: 'messages.push({ role: "assistant"', check: 'if (response.stop_reason !== "tool_use")', tool: "const out = runTool(", results: 'messages.push({ role: "user", content: results })', done: "return { stop: response.stop_reason", }; const state = { mode: "watch", seedIndex: 0, lake: null, run: 0, paused: false, stepWaiter: null, speed: 1, fog: false, concepts: new Set(), bars: [], toolCells: new Map(), // tool_use id -> cell, for hover links started: false, messages: [], abort: null, }; let scene; const ui = {}; // --------------------------------------------------------------------------- // Boot export async function boot(root) { Object.assign(ui, { root, stage: $("#stage", root), caption: $("#capText", root), capKind: $("#capKind", root), turn: $("#turnChip", root), stop: $("#stopChip", root), eye: $("#eyeToggle", root), msgs: $("#msgs", root), msgCount: $("#msgCount", root), code: $("#loopCode", root), req: $("#lastReq", root), toolsPane: $("#toolsJson", root), bars: $("#bars", root), tokNote: $("#tokNote", root), concepts: $("#concepts", root), play: $("#playBtn", root), step: $("#stepBtn", root), speedBtn: $("#speedBtn", root), newBtn: $("#newBtn", root), composer: $("#composer", root), live: $("#livebox", root), transport: $(".transport", root), }); root.classList.add("js"); // On phones the caption sits under the lake instead of over it. const capBox = ui.caption.parentElement, narrow = matchMedia("(max-width: 900px)"); const placeCaption = () => (narrow.matches ? ui.stage.after(capBox) : ui.stage.append(capBox)); narrow.addEventListener("change", placeCaption); placeCaption(); const lite = params.has("lite") || navigator.connection?.saveData; let use3d = !lite; if (use3d) { const { LakeScene, webglAvailable } = await import("./scene.js"); if (webglAvailable()) { try { scene = new LakeScene(ui.stage, { reducedMotion }); } catch { use3d = false; } } else use3d = false; } if (!scene) { const { FlatScene } = await import("./scene2d.js"); scene = new FlatScene(ui.stage, { reducedMotion }); } root.classList.toggle("is-flat", !use3d); root.classList.add("ready"); renderCode(); renderTools(); renderConcepts(); bindControls(); bindHover(); setMode("watch", { autostart: false }); // Start the demo when the lab scrolls into view. new IntersectionObserver(([e], obs) => { if (e.isIntersecting && !state.started) { state.started = true; obs.disconnect(); startRun(); } }, { threshold: 0.35 }).observe(ui.stage); } // --------------------------------------------------------------------------- // Running function freshLake() { const seed = state.mode === "watch" ? WATCH_SEEDS[state.seedIndex % WATCH_SEEDS.length] : 1000 + Math.floor(Math.random() * 9000); state.lake = createLake(seed); scene.reset(state.lake); syncScene(); } function syncScene() { const fog = state.mode === "model" ? !state.revealed : state.fog; scene.sync(state.lake, { fog }); } function resetPanels() { ui.msgs.innerHTML = ""; state.messages = []; state.toolCells.clear(); state.bars = []; ui.bars.innerHTML = ""; ui.tokNote.textContent = ""; ui.req.innerHTML = ""; state.concepts.clear(); renderConcepts(); setTurn(0); setStop("ready"); highlight(null); } function cancelRun() { state.run++; state.abort?.abort(); state.abort = null; if (state.stepWaiter) { state.stepWaiter(); state.stepWaiter = null; } if (state.humanWaiter) { state.humanWaiter.reject(new Cancelled()); state.humanWaiter = null; } delete ui.root.dataset.running; delete ui.root.dataset.waiting; } async function startRun() { cancelRun(); const run = state.run; state.revealed = false; resetScriptedIds(); freshLake(); resetPanels(); ui.root.dataset.running = "1"; let callModel; if (state.mode === "watch") { callModel = async ({ messages }) => { await pace(run, 0.7); const r = scriptedTurn(messages); return { ...r, usage: null }; }; } else if (state.mode === "model") { callModel = ({ messages }) => humanTurn(run, messages); } else { const key = $("#apiKey").value.trim(); const model = $("#modelSel").value; state.abort = new AbortController(); const { makeClaude } = await import("./claude.js"); callModel = await makeClaude({ apiKey: key, model, signal: state.abort.signal, onParams: (p) => showRequest(p) }); } try { await agentLoop({ callModel, lake: state.lake, maxTurns: 30, on: (ev, d) => { state.loopMessages = d.messages ?? state.loopMessages; // the real array, for the JSON view if (ui.msgs.classList.contains("raw")) refreshRaw(); return onEvent(run, ev, d); }, }); } catch (err) { if (err instanceof Cancelled || run !== state.run) return; if (err?.name === "AbortError" || state.abort?.signal.aborted) return; const { describeError } = await import("./claude.js"); const msg = describeError(err); $("#liveErr").textContent = msg; setStop("error"); caption("Error", esc(msg)); highlight(null); } finally { if (run === state.run) { delete ui.root.dataset.running; updateTransport(); if (state.mode === "live") $("#runLive").disabled = false; } } } // Wait between steps, honoring pause, step and speed. Throws if the run was replaced. async function pace(run, seconds) { if (run !== state.run) throw new Cancelled(); if (state.mode === "watch") { if (state.paused) await new Promise((r) => (state.stepWaiter = r)); else await sleep((seconds * 1000) / state.speed); } else { await sleep(seconds * 300); } if (run !== state.run) throw new Cancelled(); } async function onEvent(run, ev, d) { if (run !== state.run) throw new Cancelled(); const live = state.mode === "live"; switch (ev) { case "start": { addMessage(d.messages[0]); caption("Start", `The run starts with one user message: the task. The model also gets a system prompt and ${TOOLS.length} tool definitions. That's everything it will ever know about this lake.`); await pace(run, 1.8); break; } case "request": { highlight("request"); setTurn(d.turn); const est = estimateTokens(d.messages); addBar({ est, live }); if (d.turn >= 3) concept("history"); caption(`Turn ${d.turn}`, d.turn === 1 ? "The loop sends the task to the model and waits for an answer." : `The whole conversation, all ${d.messages.length} messages, goes to the model again. It doesn't remember earlier calls. The history is its memory.`); if (live) ui.root.dataset.waiting = "1"; if (state.mode !== "model") await pace(run, 0.5); if (state.mode === "watch") showRequest(previewParams("scripted-stand-in", d.messages)); break; } case "response": { delete ui.root.dataset.waiting; highlight("response"); const r = d.response; if (r.usage) updateBarUsage(r.usage); addMessage({ role: "assistant", content: r.content }); const uses = r.content.filter((b) => b.type === "tool_use"); if (uses.length) concept("tools"); if (uses.length > 1) concept("parallel"); if (uses.length > 1) { const names = uses.map((u) => `${u.name}`); const list = names.length === 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`; caption("Parallel", `The model asked for ${uses.length} tools in one turn: ${list}. They don't depend on each other, so it asked for ${uses.length === 2 ? "both" : "all of them"} at once.`); } else if (uses.length) caption("tool_use", `The model can't touch the lake. It answered with a tool_use block asking your code to run ${uses[0].name}.`); await pace(run, uses.length ? 1.4 : 0.6); highlight("check"); setStop(r.stop_reason); await pace(run, 0.5); break; } case "tool": { highlight("tool"); const ev2 = d.out.event; const cell = ev2.cell || ev2.to; if (cell) state.toolCells.set(d.block.id, cell); addResult(d.block, d.out); await scene.play(ev2, state.mode === "watch" ? state.speed : 1.4); syncScene(); if (d.out.isError) { concept("errors"); caption("is_error", `The tool refused. The error goes back as a tool_result with is_error: true, and the model gets a chance to recover. The safety rule lives in the tool, not only in the prompt.`); await pace(run, 1.6); } else if (ev2.type === "drill" && ev2.inches < 4) { caption("Thin ice", `${cellName(...ev2.cell)} measured ${ev2.inches === 0 ? "open water" : `${ev2.inches} inches`}. The model reads only that number, and has to steer around it.`); await pace(run, 1.2); } break; } case "results": { highlight("results"); finishResults(); caption("tool_result", d.results.length > 1 ? `Your code sends all ${d.results.length} results back in one user message, each matched to its call by tool_use_id.` : "Your code sends the result back as a user message, matched to the call by tool_use_id. Then the loop goes around again."); await pace(run, 0.9); break; } case "done": { highlight("done"); concept("stop"); setStop(d.stop); const requests = d.messages.filter((m) => m.role === "assistant").length; const calls = d.messages.flatMap((m) => (Array.isArray(m.content) ? m.content : [])).filter((b) => b.type === "tool_use").length; const why = { end_turn: "The model stopped asking for tools", max_tokens: "The model ran out of output tokens", refusal: "The model declined", max_turns: "The loop hit its 30 turn safety cap" }[d.stop] || `The run stopped (${d.stop})`; caption("Done", `stop_reason: ${esc(d.stop)}. ${why}, so the loop ends. ${requests} requests, ${calls} tool calls${state.lake.shanty ? ", and one shanty on the ice." : "."}`); if (state.mode === "model") { state.revealed = true; syncScene(); } ui.composer.hidden = true; break; } } } // --------------------------------------------------------------------------- // You're the model function humanTurn(run, messages) { return new Promise((resolve, reject) => { if (run !== state.run) return reject(new Cancelled()); state.humanWaiter = { resolve, reject }; state.draft = []; renderDraft(); $("#cmpText").value = ""; ui.composer.hidden = false; ui.composer.classList.remove("in"); requestAnimationFrame(() => ui.composer.classList.add("in")); caption("Your turn", messages.length === 1 ? "You're the model now. You can't see the lake: it's fog, because a model only gets text. Read the task, pick one or more tools, and send your turn." : "Read the latest tool results in Messages. Then pick your next tool calls. Everything you know came from that text."); setStop("your turn"); }); } let youId = 0; function sendHumanTurn(finish) { const w = state.humanWaiter; if (!w) return; const note = $("#cmpText").value.trim(); const content = []; if (note || finish) content.push({ type: "text", text: note || "Done." }); if (!finish) { if (!state.draft.length) { flash($("#draft")); return; } for (const d of state.draft) content.push({ type: "tool_use", id: `toolu_you_${String(++youId).padStart(3, "0")}`, name: d.name, input: d.input }); } state.humanWaiter = null; ui.composer.hidden = true; w.resolve({ content, stop_reason: finish ? "end_turn" : "tool_use", usage: null }); } function renderDraft() { const list = $("#draft"); list.innerHTML = state.draft.length ? state.draft.map((d, i) => `
  • tool_use ${d.name} ${esc(JSON.stringify(d.input))}
  • `).join("") : `
  • No tool calls yet. Pick one below.
  • `; $("#sendTurn").disabled = !state.draft.length; } // --------------------------------------------------------------------------- // Panels function caption(kind, html) { ui.capKind.textContent = kind; ui.caption.innerHTML = html; const cap = ui.caption.parentElement; cap.classList.remove("pop"); void cap.offsetWidth; cap.classList.add("pop"); } function setTurn(n) { ui.turn.textContent = n ? `Turn ${n}` : "Turn 0"; } function setStop(s) { ui.stop.textContent = s === "ready" ? "ready" : s === "your turn" ? "your turn" : `stop_reason: ${s}`; ui.stop.dataset.kind = s; } const TAGS = ["#7fd6ff", "#ffb36b", "#b5f0c7", "#f2a7ff", "#ffe08a", "#9fb4ff"]; let tagIndex = 0; const tagFor = new Map(); function tag(id) { if (!tagFor.has(id)) tagFor.set(id, TAGS[tagIndex++ % TAGS.length]); return tagFor.get(id); } function blockHTML(b) { if (typeof b === "string") return `
    ${esc(b)}
    `; switch (b.type) { case "text": return `
    ${esc(b.text)}
    `; case "thinking": return `
    thinking${b.thinking ? esc(b.thinking) : "reasoning hidden"}
    `; case "redacted_thinking": return `
    thinkingredacted
    `; case "tool_use": return `
    tool_use${esc(b.name)}${esc(JSON.stringify(b.input))}${esc(b.id)}
    `; case "tool_result": return resultHTML(b); default: return `
    ${esc(b.type)}
    `; } } function resultHTML(r) { let body = r.content; try { body = JSON.stringify(JSON.parse(r.content)); } catch { /* plain text */ } return `
    tool_result${r.is_error ? " ยท is_error" : ""}${esc(body)}${esc(r.tool_use_id)}
    `; } function addMessage(m) { state.messages.push(m); const li = document.createElement("li"); li.className = `msg ${m.role}`; const blocks = typeof m.content === "string" ? [m.content] : m.content; li.innerHTML = `
    ${m.role}messages[${state.messages.length - 1}]
    ${blocks.map(blockHTML).join("")}`; ui.msgs.appendChild(li); ui.msgCount.textContent = state.messages.length; requestAnimationFrame(() => li.classList.add("in")); scrollMsgs(); return li; } // Results stream into one pending user message as each tool finishes. let pending = null; function addResult(block, out) { if (!pending) { pending = document.createElement("li"); pending.className = "msg user pending"; pending.innerHTML = `
    usermessages[${state.messages.length}]
    `; ui.msgs.appendChild(pending); requestAnimationFrame(() => pending.classList.add("in")); } pending.insertAdjacentHTML("beforeend", resultHTML({ tool_use_id: block.id, content: out.text, is_error: out.isError })); scrollMsgs(); } function finishResults() { if (!pending) return; pending.classList.remove("pending"); pending = null; state.messages.push({ role: "user", content: "(tool results)" }); ui.msgCount.textContent = state.messages.length; } function scrollMsgs() { const pane = ui.msgs.closest(".pane"); pane.scrollTo({ top: pane.scrollHeight, behavior: reducedMotion ? "auto" : "smooth" }); } function renderCode() { const lines = LOOP_SOURCE.split("\n"); ui.code.innerHTML = lines.map((line) => { const phase = Object.keys(LINES).find((k) => line.includes(LINES[k])); return `${highlightJS(line) || " "}`; }).join(""); } function highlightJS(line) { const c = line.indexOf("//"); const code = c >= 0 ? line.slice(0, c) : line; const comment = c >= 0 ? line.slice(c) : ""; let html = esc(code) .replace(/("[^&]*?")/g, '$1') .replace(/\b(import|from|export|async|function|const|let|await|for|of|if|continue|return|while|true|false)\b/g, '$1'); if (comment) html += `${esc(comment)}`; return html; } function highlight(phase) { $$(".ln.on", ui.code).forEach((l) => l.classList.remove("on")); if (!phase) return; const el = $(`.ln[data-phase="${phase}"]`, ui.code); if (!el) return; el.classList.add("on"); const pane = ui.code.closest(".pane"); if (!pane.hidden) { const top = el.offsetTop - pane.clientHeight / 2; pane.scrollTo({ top, behavior: reducedMotion ? "auto" : "smooth" }); } } function renderTools() { ui.toolsPane.innerHTML = `// system\n${esc(SYSTEM_PROMPT)}\n\n// tools\n${esc(JSON.stringify(TOOLS, null, 2))}\n\n// messages[0]\n${esc(TASK)}`; } function previewParams(model, messages) { return { model, max_tokens: 16000, system: SYSTEM_PROMPT, tools: TOOLS, messages }; } function showRequest(p) { const summary = { ...p, system: `${p.system.slice(0, 48)}... (${p.system.length} chars)`, tools: p.tools.map((t) => t.name), messages: `[${p.messages.length} messages]`, }; ui.req.innerHTML = `
    Request body, turn ${p.messages.filter((m) => m.role === "assistant").length + 1}
    ${esc(JSON.stringify(summary, null, 2))}
    `; } function renderConcepts() { ui.concepts.innerHTML = Object.entries(CONCEPTS).map(([k, [name, tip]]) => `${name}`).join("") + `${state.concepts.size}/5`; } function concept(k) { if (state.concepts.has(k)) return; state.concepts.add(k); renderConcepts(); $(`.concept[data-k="${k}"]`, ui.concepts)?.classList.add("pop"); } // Token bars: one per request, so you can watch the history grow. function estimateTokens(messages) { return Math.round(JSON.stringify({ system: SYSTEM_PROMPT, tools: TOOLS, messages }).length / 3.6); } function addBar({ est, live }) { const bar = { est, el: document.createElement("span") }; bar.el.className = "bar" + (live ? " wait" : ""); bar.el.innerHTML = ``; ui.bars.appendChild(bar.el); state.bars.push(bar); drawBars(); } function updateBarUsage(u) { const bar = state.bars.at(-1); if (!bar) return; bar.live = true; bar.fresh = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0); bar.cached = u.cache_read_input_tokens || 0; bar.el.classList.remove("wait"); drawBars(); } function drawBars() { const totals = state.bars.map((b) => (b.live ? b.fresh + b.cached : b.est)); const max = Math.max(4000, ...totals); state.bars.forEach((b, i) => { const total = totals[i]; b.el.style.height = `${(total / max) * 100}%`; b.el.querySelector(".cached").style.height = b.live && total ? `${(b.cached / total) * 100}%` : "0%"; b.el.title = b.live ? `${total.toLocaleString()} input tokens, ${b.cached.toLocaleString()} read from cache` : `About ${total.toLocaleString()} tokens`; }); const last = state.bars.at(-1); if (!last) { ui.tokNote.textContent = ""; return; } ui.tokNote.innerHTML = last.live ? `${(last.fresh + last.cached).toLocaleString()} in the last request, ${last.cached.toLocaleString()} cached` : `about ${last.est.toLocaleString()} in the last request`; } // --------------------------------------------------------------------------- // Controls function setMode(mode, { autostart = true } = {}) { cancelRun(); state.mode = mode; ui.root.dataset.mode = mode; $$("[data-mode]", ui.root).forEach((b) => b.setAttribute("aria-selected", String(b.dataset.mode === mode))); ui.composer.hidden = true; ui.live.hidden = mode !== "live"; ui.eye.disabled = mode === "model"; ui.eye.setAttribute("aria-pressed", String(mode === "model" || state.fog)); state.paused = false; updateTransport(); resetScriptedIds(); freshLake(); resetPanels(); if (mode === "watch") { if (autostart) startRun(); else caption("Watch", "A scripted agent is about to guide the sled across the lake. Watch the messages and the loop code as it goes."); } else if (mode === "model") { if (autostart) startRun(); } else { addMessage({ role: "user", content: TASK }); caption("Live", "Paste your own Anthropic API key and run the real Claude on a fresh lake. Every request and response shows up here as it happens."); $("#runLive").disabled = false; } } function updateTransport() { const watch = state.mode === "watch"; ui.play.hidden = ui.step.hidden = ui.speedBtn.hidden = !watch; ui.play.textContent = state.paused ? "Play" : "Pause"; ui.play.setAttribute("aria-pressed", String(state.paused)); ui.step.disabled = !state.paused; ui.newBtn.textContent = state.mode === "live" ? "New lake" : "Restart on a new lake"; } function bindControls() { $$("[data-mode]", ui.root).forEach((b) => b.addEventListener("click", () => { if (state.mode !== b.dataset.mode) { state.started = true; setMode(b.dataset.mode); } })); $$("[data-tab]", ui.root).forEach((b) => b.addEventListener("click", () => { $$("[data-tab]", ui.root).forEach((x) => x.setAttribute("aria-selected", String(x === b))); $$("[data-pane]", ui.root).forEach((p) => (p.hidden = p.dataset.pane !== b.dataset.tab)); if (b.dataset.tab === "code") highlight($(".ln.on", ui.code)?.dataset.phase); })); ui.play.addEventListener("click", () => { state.paused = !state.paused; if (!state.paused && state.stepWaiter) { state.stepWaiter(); state.stepWaiter = null; } if (!state.started) { state.started = true; startRun(); } updateTransport(); }); ui.step.addEventListener("click", () => { if (state.stepWaiter) { const w = state.stepWaiter; state.stepWaiter = null; w(); } }); ui.speedBtn.addEventListener("click", () => { state.speed = state.speed === 1 ? 2 : state.speed === 2 ? 4 : 1; ui.speedBtn.textContent = `${state.speed}x`; }); ui.newBtn.addEventListener("click", () => { state.seedIndex++; state.started = true; if (state.mode === "live") { cancelRun(); freshLake(); resetPanels(); addMessage({ role: "user", content: TASK }); $("#runLive").disabled = false; } else startRun(); }); ui.eye.addEventListener("click", () => { state.fog = !state.fog; ui.eye.setAttribute("aria-pressed", String(state.fog)); syncScene(); }); $("#jsonToggle", ui.root).addEventListener("click", (e) => { const on = ui.msgs.classList.toggle("raw"); e.currentTarget.setAttribute("aria-pressed", String(on)); $("#rawJson").hidden = !on; if (on) refreshRaw(); }); // Composer. ui.composer.addEventListener("click", (e) => { const t = e.target.closest("button"); if (!t) return; if (t.dataset.tool) { const input = t.dataset.dir ? { direction: t.dataset.dir } : {}; if (state.draft.length >= 4) { flash($("#draft")); return; } state.draft.push({ name: t.dataset.tool, input }); renderDraft(); } else if (t.dataset.rm !== undefined) { state.draft.splice(Number(t.dataset.rm), 1); renderDraft(); } else if (t.id === "sendTurn") sendHumanTurn(false); else if (t.id === "finishTurn") sendHumanTurn(true); }); // Live. $("#runLive").addEventListener("click", () => { const key = $("#apiKey").value.trim(); $("#liveErr").textContent = ""; if (!/^sk-ant-/.test(key)) { $("#liveErr").textContent = "Paste an Anthropic API key. It starts with sk-ant-."; return; } $("#runLive").disabled = true; state.started = true; startRun(); }); $("#stopLive").addEventListener("click", () => { cancelRun(); delete ui.root.dataset.running; delete ui.root.dataset.waiting; $("#runLive").disabled = false; setStop("stopped"); caption("Stopped", "You stopped the run. Nothing else will be sent."); }); } // The raw transcript, as the loop holds it (thinking signatures shortened). function rawMessages() { const scrub = (b) => (b && b.signature ? { ...b, signature: b.signature.slice(0, 16) + "..." } : b); return state.loopMessages?.map((m) => (Array.isArray(m.content) ? { ...m, content: m.content.map(scrub) } : m)) ?? []; } function refreshRaw() { $("#rawJson").textContent = JSON.stringify(rawMessages(), null, 2); } function bindHover() { // Hovering a tool block lights up its partner and the cell it touched. ui.msgs.addEventListener("pointerover", (e) => { const b = e.target.closest("[data-id]"); $$(".blk.link", ui.msgs).forEach((x) => x.classList.remove("link")); if (!b) { scene.setHover(null); return; } $$(`[data-id="${CSS.escape(b.dataset.id)}"]`, ui.msgs).forEach((x) => x.classList.add("link")); scene.setHover(state.toolCells.get(b.dataset.id) || null); }); ui.msgs.addEventListener("pointerleave", () => { scene.setHover(null); $$(".blk.link", ui.msgs).forEach((x) => x.classList.remove("link")); }); ui.stage.addEventListener("pointermove", (e) => { if (e.pointerType !== "mouse") return; const cell = scene.pick(e.clientX, e.clientY); scene.setHover(cell); ui.stage.title = cell ? cellName(...cell) : ""; }); ui.stage.addEventListener("pointerleave", () => scene.setHover(null)); } function flash(el) { el.classList.remove("shake"); void el.offsetWidth; el.classList.add("shake"); }