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:
12 files changed
+1796
No files matched your search
+280
@@ -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");
|
||||
Reference in new issue
Block a user