Add web UI with Smokey's crow as the stand-in mascot

- ui/: Vite + plain TypeScript, builds into ./web for `crows serve`
- CrowView mirrors CrowModel in RIVE_CONTRACT.md (pose, trust,
  reactions) so the .riv can replace the SVG later
- Event handling follows the contract's mapping table; server text
  is only ever inserted as text
- Prank: toast plus a send-button cooldown countdown
- art/crow.svg: source art for Rive import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
SmokeyandClaude Opus 5.5 committed 2026-10-27 11:44:54 -06:00
1 parent 940c74672a
commit a89157803b
12 files changed
+1796

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
import "./style.css";
import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api";
import { svgCrow, type CrowView, type Role } from "./crow";
// ---------- page ----------
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const perch = $<HTMLElement>("perch");
const cards = $<HTMLOListElement>("cards");
const form = $<HTMLFormElement>("composer");
const listInput = $<HTMLTextAreaElement>("list");
const sendBtn = $<HTMLButtonElement>("send");
const requesterInput = $<HTMLInputElement>("requester");
const banner = $<HTMLParagraphElement>("banner");
const toast = $<HTMLDivElement>("toast");
const meter = $<HTMLParagraphElement>("meter");
const store = {
get(k: string) { try { return localStorage.getItem(k); } catch { return null; } },
set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* storage off */ } },
};
requesterInput.value = store.get("requester") ?? "";
requesterInput.addEventListener("change", () => store.set("requester", requesterInput.value.trim()));
// ---------- the flock on screen ----------
const crows = new Map<string, CrowView>();
let trust: TrustLevels = new Map();
function roleOf(name: string): Role {
return name === "Sentry" ? "sentry" : name === "Elder" ? "elder" : "crow";
}
function crow(name: string): CrowView {
let c = crows.get(name);
if (!c) {
c = svgCrow(name, roleOf(name));
crows.set(name, c);
perch.append(c.el);
}
return c;
}
// Badges show trust for the task type the crow is working on now.
function showTrust(name: string, taskType: string) {
const key = name === "Sentry" ? `Sentry|triage/${taskType}` : `${name}|${taskType}`;
crow(name).setTrust(trust.get(key) ?? 0);
}
// ---------- item cards ----------
type Card = { el: HTMLLIElement; crows: string[]; taskType: string; actions: HTMLDivElement };
const items = new Map<string, Card>();
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text; // server text always goes in as text, never HTML
return e;
}
function addCard(itemId: string, text: string) {
const li = el("li", "card");
li.dataset.state = "waiting";
const head = el("div", "card-head");
head.append(el("span", "dot"), el("p", "text", text));
const actions = el("div", "actions");
li.append(head, el("div", "chips"), el("p", "reason"), el("ul", "subtasks"), el("blockquote", "answer"), actions);
li.querySelector<HTMLElement>(".reason")!.hidden = true;
li.querySelector<HTMLElement>(".answer")!.hidden = true;
cards.append(li);
items.set(itemId, { el: li, crows: [], taskType: "other", actions });
}
function ask(itemId: string, target: "triage" | "output") {
const card = items.get(itemId);
if (!card) return;
card.el.dataset.state = "asking";
card.actions.replaceChildren();
const q = el("p", "question", target === "triage" ? "Approve the Sentry's plan?" : "Accept this answer?");
const yes = el("button", "btn btn-go", target === "triage" ? "Fly it" : "Looks good");
const no = el("button", "btn btn-no", target === "triage" ? "Not like that" : "Not right");
const err = el("p", "err");
for (const [b, approve] of [[yes, true], [no, false]] as const) {
b.type = "button";
b.addEventListener("click", async () => {
yes.disabled = no.disabled = true;
err.textContent = "";
try {
await postVerdict(itemId, target, approve);
} catch (e) {
err.textContent = (e as Error).message;
yes.disabled = no.disabled = false;
}
});
}
const row = el("div", "buttons");
row.append(no, yes);
card.actions.append(q, row, err);
yes.focus({ preventScroll: true });
}
// ---------- events (mapping from RIVE_CONTRACT.md section 3) ----------
type Payload = Record<string, any>;
let source: EventSource | null = null;
const handlers: Record<string, (d: Payload) => void> = {
"list.started": (d) => {
crow("Sentry").setPose("perched");
for (const it of d.items ?? []) addCard(it.item_id, it.text);
},
"item.triaged": (d) => {
const card = items.get(d.item_id);
if (!card) return;
card.taskType = d.task_type;
const chips = card.el.querySelector(".chips")!;
chips.replaceChildren(el("span", `chip mode-${d.mode}`, d.mode), el("span", "chip", d.task_type));
const reason = card.el.querySelector<HTMLElement>(".reason")!;
reason.textContent = d.reason;
reason.hidden = !d.reason;
card.el.querySelector(".subtasks")!.replaceChildren(...(d.subtasks ?? []).map((s: string) => el("li", "", s)));
showTrust("Sentry", d.task_type);
if (d.needs_approval) {
crow("Sentry").setPose("waiting");
ask(d.item_id, "triage");
}
},
"crow.flying": (d) => {
const card = items.get(d.item_id);
if (card) {
card.el.dataset.state = "flying";
if (d.crow !== "Sentry") showTrust(d.crow, card.taskType);
}
crow(d.crow).setPose("flying");
},
"crow.landed": (d) => crow(d.crow).setPose(d.ok ? "perched" : "grounded"),
"item.answered": (d) => {
const card = items.get(d.item_id);
if (!card) return;
card.crows = d.crows ?? [];
const answer = card.el.querySelector<HTMLElement>(".answer")!;
answer.textContent = d.answer;
answer.hidden = false;
const chips = card.el.querySelector(".chips")!;
chips.append(el("span", "chip crows", card.crows.join(", ")));
if (d.needs_approval) {
for (const n of card.crows) crow(n).setPose("waiting");
ask(d.item_id, "output");
}
},
verdict: (d) => {
const card = items.get(d.item_id);
if (!card) return;
card.actions.replaceChildren();
const good = d.verdict === "approved" || d.verdict === "auto";
const who = d.target === "triage" ? ["Sentry"] : card.crows;
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
for (const n of who) {
crow(n).fire(good ? "cheer" : "sulk");
crow(n).setPose("perched");
}
if (d.target === "output") {
card.el.dataset.state = good ? "done" : "rejected";
if (d.verdict === "auto") card.actions.append(el("p", "note", "Trusted crows: accepted without asking."));
} else if (!good) {
card.el.dataset.state = "rejected";
} else {
card.el.dataset.state = "flying";
}
},
"trust.changed": (d) => {
trust.set(d.holder === "Sentry" ? `Sentry|${d.task_type}` : `${d.holder}|${d.task_type}`, d.to);
const names = ["stranger", "regular", "trusted"];
if (d.holder === "flock") {
showBanner(d.to > d.from
? `The flock earned "${names[d.to]}" on ${d.task_type}.`
: `The flock dropped to "${names[d.to]}" on ${d.task_type}.`);
return;
}
const c = crow(d.holder);
c.setTrust(d.to);
c.fire(d.to > d.from ? "levelUp" : "levelDown");
if (d.to > d.from) showBanner(`${d.holder} leveled up: ${names[d.to]} on ${String(d.task_type).replace("triage/", "triage of ")}.`);
},
"item.skipped": (d) => {
const card = items.get(d.item_id);
if (!card) return;
card.el.dataset.state = "skipped";
card.actions.replaceChildren(el("p", "note", `Skipped: ${d.reason}`));
for (const n of card.crows) crow(n).setPose("perched");
},
"requester.pranked": (d) => prank(d.message, d.cooldown_s),
"list.finished": (d) => {
for (const c of crows.values()) { c.setPose("perched"); c.fire("cheer"); }
meter.textContent = `The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`;
meter.hidden = false;
source?.close();
source = null;
sendBtn.disabled = false;
},
};
// ---------- toasts ----------
let bannerTimer = 0;
function showBanner(text: string) {
banner.textContent = text;
banner.hidden = false;
clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => (banner.hidden = true), 5000);
}
// The prank: toast for a few seconds, and the send button counts down the cooldown.
let toastTimer = 0;
let cooldownTimer = 0;
const sendLabel = sendBtn.textContent ?? "Send the flock";
function prank(message: string, cooldownS: number) {
crow("Sentry").fire("prank");
toast.textContent = message;
toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => (toast.hidden = true), 5000);
let left = Math.max(0, Math.round(cooldownS));
clearInterval(cooldownTimer);
const tick = () => {
if (left <= 0) {
clearInterval(cooldownTimer);
sendBtn.textContent = sendLabel;
sendBtn.disabled = !!source;
return;
}
sendBtn.disabled = true;
sendBtn.textContent = `The flock is ignoring you · ${left--}s`;
};
tick();
cooldownTimer = window.setInterval(tick, 1000);
}
// ---------- send a list ----------
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = listInput.value.trim();
if (!text) { listInput.focus(); return; }
const requester = requesterInput.value.trim() || "anonymous";
store.set("requester", requester);
sendBtn.disabled = true;
const res = await postList(text, requester).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" }));
if (!res.ok) {
if (res.pranked) {
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
} else {
showBanner(res.message);
sendBtn.disabled = false;
}
return;
}
// Fresh list: clear the old cards, keep the crows (they remember you).
cards.replaceChildren();
items.clear();
meter.hidden = true;
trust = await getTrust();
source?.close();
source = listEvents(res.listId);
for (const [name, fn] of Object.entries(handlers)) {
source.addEventListener(name, (ev) => {
try { fn(JSON.parse((ev as MessageEvent).data)); } catch (err) { console.error(name, err); }
});
}
});
// The Sentry is always on watch.
crow("Sentry");