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
@@ -4,3 +4,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
/somanycrows
|
||||
/web/
|
||||
@@ -19,6 +19,7 @@ Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; st
|
||||
## Web server
|
||||
|
||||
```
|
||||
cd ui && npm install && npm run build && cd .. # builds the UI into ./web
|
||||
./crows serve # localhost:8080; API + Server-Sent Events (SSE); Ctrl-C to stop
|
||||
```
|
||||
|
||||
@@ -34,6 +35,7 @@ curl localhost:8080/api/trust
|
||||
- Item IDs are `<list_id>-<n>`, numbered from 1 in list order.
|
||||
- Approvals left unanswered for 10 minutes count as rejected (`verdict: timeout`). `-approval-timeout` changes this for testing.
|
||||
- A flooding requester gets 429 with `message` and `cooldown_s`; any list of theirs that is still flying also gets `requester.pranked`.
|
||||
- UI development: run `crows serve`, then `cd ui && npm run dev` (Vite proxies `/api`). The crow art is `art/crow.svg`; the UI's `CrowView` mirrors `RIVE_CONTRACT.md`, so a Rive-backed view replaces the SVG stand-in later.
|
||||
- POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists.
|
||||
|
||||
## How an item flies
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="352.23" height="324" viewBox="0 0 352.23 324">
|
||||
<g>
|
||||
<path d="M0 162 C0 72.53 72.53 0 162 0 C251.47 0 324 72.53 324 162 C324 251.47 251.47 324 162 324 C72.53 324 0 251.47 0 162 Z" fill="#b7e4c7"/>
|
||||
<g>
|
||||
<path d="M243.202 80.08 C327.794 112.356 367.553 172.894 346.818 233.061 C342.916 220.946 302.872 200.415 210.67 163.256 Z" fill="#000000"/>
|
||||
<path d="M210.55 49.78 C255.26 73.245 275.69 124.801 261.538 171.398 C257.74 183.902 251.875 195.687 247.44 207.98 L224.829 270.652 C222.895 276.013 220.784 280.162 218.578 282.934 C177.323 334.79 20.637 303.162 11.538 192.677 C10.203 176.463 16.943 157.887 30.416 140.65 C92.996 60.586 135.157 10.212 210.55 49.78 Z M161.548 85.048 C154.947 81.583 145.85 85.91 141.23 94.713 C136.61 103.515 138.217 113.46 144.819 116.925 C151.421 120.389 160.518 116.062 165.138 107.26 C169.757 98.457 168.15 88.513 161.548 85.048 Z" fill="#000000"/>
|
||||
<path d="M193.912 193.978 C232.197 241.52 277.265 249.977 300.252 250.431 C277.193 225.895 274.197 220.917 228.645 169.687 Z" fill="#000000"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>So Many Crows</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@600;800;900&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>So Many Crows</h1>
|
||||
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="perch" id="perch" aria-label="The flock"></section>
|
||||
<p class="banner" id="banner" role="status" hidden></p>
|
||||
|
||||
<form class="composer" id="composer">
|
||||
<label for="list">Your todo list, one item per line</label>
|
||||
<textarea id="list" rows="5" placeholder="- [ ] What is the difference between a crow and a raven - [ ] Draft a tagline for So Many Crows"></textarea>
|
||||
<button class="btn btn-go" id="send" type="submit">Send the flock</button>
|
||||
</form>
|
||||
|
||||
<ol class="cards" id="cards" aria-live="polite"></ol>
|
||||
<p class="meter" id="meter" hidden></p>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast" role="alert" hidden></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1135
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "so-many-crows-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Thin client for the HTTP API in EVENTS.md.
|
||||
|
||||
export type PostListResult =
|
||||
| { ok: true; listId: string }
|
||||
| { ok: false; pranked: true; message: string; cooldownS: number }
|
||||
| { ok: false; pranked: false; message: string };
|
||||
|
||||
export async function postList(text: string, requester: string): Promise<PostListResult> {
|
||||
const res = await fetch("/api/lists", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text, requester }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.ok) return { ok: true, listId: String(body.list_id) };
|
||||
if (res.status === 429) return { ok: false, pranked: true, message: String(body.message ?? "Caw."), cooldownS: Number(body.cooldown_s ?? 30) };
|
||||
return { ok: false, pranked: false, message: String(body.message ?? `The flock said no (${res.status}).`) };
|
||||
}
|
||||
|
||||
export async function postVerdict(itemId: string, target: "triage" | "output", approve: boolean): Promise<void> {
|
||||
const res = await fetch("/api/verdicts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ item_id: itemId, target, approve }),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status === 404 ? "That question already has an answer." : `Verdict failed (${res.status}).`);
|
||||
}
|
||||
|
||||
export type TrustLevels = Map<string, number>; // "Name|task_type" -> level
|
||||
|
||||
export async function getTrust(): Promise<TrustLevels> {
|
||||
const out: TrustLevels = new Map();
|
||||
try {
|
||||
const res = await fetch("/api/trust");
|
||||
const body = await res.json();
|
||||
for (const [key, rec] of Object.entries<{ level?: number }>(body.crows ?? {})) out.set(key, rec.level ?? 0);
|
||||
} catch {
|
||||
// Badges start at zero if the ledger can't be read.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listEvents(listId: string): EventSource {
|
||||
return new EventSource(`/api/events?list_id=${encodeURIComponent(listId)}`);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// A crow on screen. This interface mirrors CrowModel in RIVE_CONTRACT.md,
|
||||
// so a Rive-backed view can replace the SVG stand-in without touching main.ts.
|
||||
|
||||
export type Role = "crow" | "sentry" | "elder";
|
||||
export type Pose = "perched" | "flying" | "waiting" | "grounded";
|
||||
export type Reaction = "cheer" | "sulk" | "levelUp" | "levelDown" | "prank";
|
||||
|
||||
export interface CrowView {
|
||||
readonly el: HTMLElement;
|
||||
setPose(pose: Pose): void;
|
||||
setTrust(level: number): void;
|
||||
fire(reaction: Reaction): void;
|
||||
}
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// Smokey's crow head: disc, upper beak, head (with eye cutout), jaw.
|
||||
const ART = `
|
||||
<path class="disc" d="M0 162 C0 72.53 72.53 0 162 0 C251.47 0 324 72.53 324 162 C324 251.47 251.47 324 162 324 C72.53 324 0 251.47 0 162 Z"/>
|
||||
<g class="bird">
|
||||
<path class="beak" d="M243.202 80.08 C327.794 112.356 367.553 172.894 346.818 233.061 C342.916 220.946 302.872 200.415 210.67 163.256 Z"/>
|
||||
<path class="head" d="M210.55 49.78 C255.26 73.245 275.69 124.801 261.538 171.398 C257.74 183.902 251.875 195.687 247.44 207.98 L224.829 270.652 C222.895 276.013 220.784 280.162 218.578 282.934 C177.323 334.79 20.637 303.162 11.538 192.677 C10.203 176.463 16.943 157.887 30.416 140.65 C92.996 60.586 135.157 10.212 210.55 49.78 Z M161.548 85.048 C154.947 81.583 145.85 85.91 141.23 94.713 C136.61 103.515 138.217 113.46 144.819 116.925 C151.421 120.389 160.518 116.062 165.138 107.26 C169.757 98.457 168.15 88.513 161.548 85.048 Z"/>
|
||||
<path class="jaw" d="M193.912 193.978 C232.197 241.52 277.265 249.977 300.252 250.431 C277.193 225.895 274.197 220.917 228.645 169.687 Z"/>
|
||||
</g>`;
|
||||
|
||||
const REACTION_MS: Record<Reaction, number> = { cheer: 800, sulk: 800, levelUp: 2000, levelDown: 1000, prank: 2500 };
|
||||
|
||||
export function svgCrow(name: string, role: Role): CrowView {
|
||||
const el = document.createElement("figure");
|
||||
el.className = `crow role-${role} pose-perched`;
|
||||
el.dataset.trust = "0";
|
||||
|
||||
const svg = document.createElementNS(NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 352.23 324");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.innerHTML = ART; // static art only; no server data goes through innerHTML
|
||||
const body = document.createElement("div");
|
||||
body.className = "crow-body";
|
||||
body.append(svg);
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge";
|
||||
const cap = document.createElement("figcaption");
|
||||
cap.textContent = name;
|
||||
el.append(body, badge, cap);
|
||||
|
||||
let timer = 0;
|
||||
return {
|
||||
el,
|
||||
setPose(pose) {
|
||||
el.classList.remove("pose-perched", "pose-flying", "pose-waiting", "pose-grounded");
|
||||
el.classList.add(`pose-${pose}`);
|
||||
el.setAttribute("aria-label", `${name}, ${pose}`);
|
||||
},
|
||||
setTrust(level) {
|
||||
el.dataset.trust = String(Math.max(0, Math.min(2, level)));
|
||||
},
|
||||
fire(reaction) {
|
||||
clearTimeout(timer);
|
||||
el.classList.remove(...Object.keys(REACTION_MS).map((r) => `r-${r}`));
|
||||
void el.offsetWidth; // restart the animation if the same reaction fires twice
|
||||
el.classList.add(`r-${reaction}`);
|
||||
timer = window.setTimeout(() => el.classList.remove(`r-${reaction}`), REACTION_MS[reaction]);
|
||||
},
|
||||
};
|
||||
}
|
||||
+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");
|
||||
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
--bg: #f6fbf7;
|
||||
--surface: #ffffff;
|
||||
--ink: #1d2721;
|
||||
--muted: #5d6b63;
|
||||
--line: #dfe9e2;
|
||||
--mint: #b7e4c7;
|
||||
--go: #2f9e5b;
|
||||
--go-edge: #1f7642;
|
||||
--no: #ffffff;
|
||||
--no-ink: #c2363b;
|
||||
--no-edge: #e3c6c7;
|
||||
--sentry: #ffe08a;
|
||||
--elder: #d6dde6;
|
||||
--bronze: #c98a4b;
|
||||
--gold: #f2b705;
|
||||
--shadow: 0 2px 0 var(--line);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #121714;
|
||||
--surface: #1b221e;
|
||||
--ink: #eef4f0;
|
||||
--muted: #9fb0a6;
|
||||
--line: #2c3731;
|
||||
--no: #1b221e;
|
||||
--no-edge: #4a2c2e;
|
||||
--no-ink: #ff8a8e;
|
||||
}
|
||||
.crow .bird { fill: #0b0d0c; }
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 600 16px/1.45 Nunito, system-ui, sans-serif;
|
||||
}
|
||||
main { max-width: 680px; margin: 0 auto; padding: 0 16px 64px; }
|
||||
|
||||
.top {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between;
|
||||
max-width: 680px; margin: 0 auto; padding: 20px 16px 8px;
|
||||
}
|
||||
.top h1 { margin: 0; font-weight: 900; font-size: 26px; letter-spacing: -0.5px; }
|
||||
.who { color: var(--muted); font-size: 14px; }
|
||||
.who input {
|
||||
margin-left: 6px; width: 140px; padding: 6px 10px; font: inherit; color: var(--ink);
|
||||
background: var(--surface); border: 2px solid var(--line); border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ---------- buttons: chunky, pressable ---------- */
|
||||
.btn {
|
||||
font: 800 16px/1 Nunito, system-ui, sans-serif; letter-spacing: 0.3px; text-transform: uppercase;
|
||||
padding: 14px 20px; border-radius: 16px; border: 2px solid transparent; cursor: pointer;
|
||||
transition: transform 80ms, box-shadow 80ms;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: translateY(4px); box-shadow: none; }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }
|
||||
.btn-go { background: var(--go); color: #fff; box-shadow: 0 4px 0 var(--go-edge); }
|
||||
.btn-no { background: var(--no); color: var(--no-ink); border-color: var(--no-edge); box-shadow: 0 4px 0 var(--no-edge); }
|
||||
|
||||
/* ---------- composer ---------- */
|
||||
.composer { display: grid; gap: 10px; margin: 8px 0 24px; }
|
||||
.composer label { font-weight: 800; }
|
||||
.composer textarea {
|
||||
width: 100%; padding: 12px 14px; font: inherit; color: var(--ink); resize: vertical;
|
||||
background: var(--surface); border: 2px solid var(--line); border-radius: 16px;
|
||||
}
|
||||
.composer textarea:focus { outline: none; border-color: var(--go); }
|
||||
|
||||
/* ---------- the perch ---------- */
|
||||
.perch {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 4px; justify-content: center; align-items: flex-end;
|
||||
min-height: 132px; padding: 16px 8px 8px; margin: 8px 0 16px;
|
||||
border-bottom: 6px solid #6b4f3a; border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.crow { margin: 0; width: 84px; display: grid; justify-items: center; position: relative; }
|
||||
.crow.role-sentry { order: -1; }
|
||||
.crow.role-elder { order: 99; }
|
||||
.crow-body { width: 72px; height: 66px; }
|
||||
.crow svg { width: 100%; height: 100%; overflow: visible; }
|
||||
.crow .disc { fill: var(--mint); }
|
||||
.crow.role-sentry .disc { fill: var(--sentry); }
|
||||
.crow.role-elder .disc { fill: var(--elder); }
|
||||
.crow .bird { fill: #000; }
|
||||
.crow .jaw { transform-origin: 211px 182px; transition: transform 150ms; }
|
||||
.crow figcaption { font-size: 13px; font-weight: 800; color: var(--muted); }
|
||||
|
||||
.badge {
|
||||
position: absolute; top: 0; right: 8px; width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 3px solid var(--bg); opacity: 0; transform: scale(0.4); transition: 150ms;
|
||||
}
|
||||
.crow[data-trust="1"] .badge { opacity: 1; transform: none; background: var(--bronze); }
|
||||
.crow[data-trust="2"] .badge { opacity: 1; transform: none; background: var(--gold); animation: shimmer 2.4s infinite; }
|
||||
|
||||
/* Body layer: poses */
|
||||
.pose-perched .crow-body { animation: breathe 3.2s ease-in-out infinite; }
|
||||
.pose-flying .crow-body { animation: flap 0.5s ease-in-out infinite alternate; }
|
||||
.pose-flying .jaw { transform: rotate(-10deg); }
|
||||
.pose-waiting .crow-body { animation: tilt 1.4s ease-in-out infinite; }
|
||||
.pose-waiting::after {
|
||||
content: "?"; position: absolute; top: -6px; left: 6px; font-weight: 900; font-size: 18px; color: var(--go);
|
||||
animation: pop 1.4s ease-in-out infinite;
|
||||
}
|
||||
.pose-grounded .crow-body { transform: rotate(-18deg) translateY(6px); filter: grayscale(1) opacity(0.6); }
|
||||
.pose-grounded::after { content: "✦ ✦"; position: absolute; top: -4px; font-size: 12px; color: var(--gold); animation: spin 1.6s linear infinite; }
|
||||
|
||||
/* Reaction layer: one-shots on top */
|
||||
.r-cheer .crow-body { animation: hop 0.8s ease-out; }
|
||||
.r-cheer .jaw { transform: rotate(14deg); }
|
||||
.r-sulk .crow-body { animation: droop 0.8s ease-out; }
|
||||
.r-levelUp .crow-body { animation: levelup 2s ease-out; }
|
||||
.r-levelUp .jaw { transform: rotate(16deg); }
|
||||
.r-levelUp::before {
|
||||
content: "★"; position: absolute; top: -18px; font-size: 22px; color: var(--gold);
|
||||
animation: rise 2s ease-out forwards;
|
||||
}
|
||||
.r-levelDown .crow-body { animation: shrug 1s ease-out; }
|
||||
.r-prank .crow-body { animation: prank 2.5s ease-in-out; }
|
||||
|
||||
@keyframes breathe { 50% { transform: scale(1.03, 0.98); } }
|
||||
@keyframes flap { from { transform: translateY(0) rotate(-3deg); } to { transform: translateY(-8px) rotate(3deg); } }
|
||||
@keyframes tilt { 50% { transform: rotate(8deg); } }
|
||||
@keyframes pop { 50% { transform: translateY(-4px); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes shimmer { 50% { box-shadow: 0 0 10px var(--gold); } }
|
||||
@keyframes hop { 30% { transform: translateY(-22px) scale(0.95, 1.08); } 60% { transform: translateY(0) scale(1.08, 0.92); } }
|
||||
@keyframes droop { 40%, 70% { transform: translateY(6px) rotate(-6deg) scale(1, 0.94); } }
|
||||
@keyframes levelup { 20% { transform: scale(1.25) rotate(0); } 60% { transform: scale(1.25) rotate(360deg); } }
|
||||
@keyframes rise { from { opacity: 1; transform: translateY(0) scale(0.6); } to { opacity: 0; transform: translateY(-30px) scale(1.4); } }
|
||||
@keyframes shrug { 30% { transform: scale(0.9) rotate(-4deg); } 60% { transform: scale(0.9) rotate(4deg); } }
|
||||
@keyframes prank { 25% { transform: translate(-120px, -60px) rotate(-30deg); opacity: 0; } 55% { transform: translate(120px, -60px); opacity: 0; } 80% { transform: scale(1.1); opacity: 1; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.crow *, .crow::before, .crow::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- cards ---------- */
|
||||
.cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||
.card {
|
||||
background: var(--surface); border: 2px solid var(--line); border-radius: 18px; padding: 14px 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card-head { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.card .text { margin: 0; font-weight: 800; }
|
||||
.dot { flex: none; width: 14px; height: 14px; margin-top: 5px; border-radius: 50%; background: var(--line); }
|
||||
.card[data-state="flying"] .dot { background: var(--mint); animation: pop 0.8s infinite; }
|
||||
.card[data-state="asking"] { border-color: var(--go); }
|
||||
.card[data-state="asking"] .dot { background: var(--go); }
|
||||
.card[data-state="done"] .dot { background: var(--go); }
|
||||
.card[data-state="rejected"] .dot, .card[data-state="skipped"] .dot { background: var(--no-ink); }
|
||||
.card[data-state="skipped"] .text { text-decoration: line-through; color: var(--muted); }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0 24px; }
|
||||
.chips:empty { display: none; }
|
||||
.chip {
|
||||
font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
padding: 2px 8px; border-radius: 999px; background: var(--bg); border: 2px solid var(--line); color: var(--muted);
|
||||
}
|
||||
.chip.mode-murder { border-color: #1d2721; color: var(--ink); }
|
||||
.chip.mode-split { border-color: var(--mint); }
|
||||
.reason { margin: 6px 0 0 24px; color: var(--muted); font-size: 14px; }
|
||||
.subtasks { margin: 6px 0 0 24px; padding-left: 18px; font-size: 14px; }
|
||||
.subtasks:empty { display: none; }
|
||||
.answer {
|
||||
margin: 10px 0 0 24px; padding: 10px 12px; border-left: 4px solid var(--mint); background: var(--bg);
|
||||
border-radius: 0 12px 12px 0; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.actions:empty { display: none; }
|
||||
.actions { margin: 12px 0 0 24px; }
|
||||
.question { margin: 0 0 10px; font-weight: 800; }
|
||||
.buttons { display: flex; gap: 10px; }
|
||||
.buttons .btn { flex: 1; }
|
||||
.note { margin: 0; font-size: 14px; color: var(--muted); }
|
||||
.err { margin: 8px 0 0; font-size: 14px; color: var(--no-ink); }
|
||||
.err:empty { display: none; }
|
||||
|
||||
.banner {
|
||||
margin: 0 0 16px; padding: 10px 14px; border-radius: 14px; background: var(--sentry); color: #3d3000;
|
||||
font-weight: 800; text-align: center;
|
||||
}
|
||||
.meter { margin: 16px 0 0; text-align: center; color: var(--muted); font-size: 14px; }
|
||||
.toast {
|
||||
position: fixed; left: 16px; right: 16px; bottom: 20px; max-width: 520px; margin: 0 auto;
|
||||
padding: 14px 16px; border-radius: 16px; background: var(--ink); color: var(--bg); font-weight: 800; text-align: center;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// Builds into ../web, which `crows serve` hosts. `npm run dev` proxies the API to a running server.
|
||||
export default defineConfig({
|
||||
build: { outDir: "../web", emptyOutDir: true },
|
||||
server: { proxy: { "/api": "http://127.0.0.1:8080" } },
|
||||
});
|
||||
Reference in new issue
Block a user