Chat layout: roster, channel feed, composer

- The flock is a member list with live status (perched, flying,
  waiting on you, grounded); a scrolling strip on phones
- The Sentry and crows post into one channel; approvals are inline
  buttons on their messages; verdicts show as reactions
- "Who's flying" line above the composer; Ctrl/Cmd+Enter to send
- Consecutive messages from one author group under one name
- RIVE_CONTRACT.md: roster size and still message avatars

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 12:36:31 -06:00
1 parent d0ae4fd1c0
commit 0733ba1d9c
5 files changed
+343 -255

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
The animator builds `crows.riv` against this file, and the web code drives it from the events in `EVENTS.md`. Both sides build against this file, so change it first and then the code. Each animation exists only because an `EVENTS.md` event fires it.
Style: Duolingo-like behavior, pen-and-ink look: cream paper, ink linework, stipple shading (see `ui/src/style.css`). Smokey's crow art (`art/crow.svg`) is the character; keep its silhouette. Expressive crows that react through motion (hops, tilts, spins) and never change size, with readable silhouettes at 96 pixels. Sparkles mean celebration only. The approve and reject buttons stay in HTML for v1 (see "Why buttons stay in HTML").
Style: Duolingo-like behavior, pen-and-ink look: cream paper, ink linework, stipple shading (see `ui/src/style.css`). Smokey's crow art (`art/crow.svg`) is the character; keep its silhouette. Expressive crows that react through motion (hops, tilts, spins) and never change size, with readable silhouettes at 40 pixels (the chat roster size). Sparkles mean celebration only. The approve and reject buttons stay in HTML for v1 (see "Why buttons stay in HTML").
## Rive approach: data binding, not inputs
@@ -21,7 +21,7 @@ Rive now marks state machine inputs as deprecated and recommends data binding fo
| `Perch` | later | A scene with a branch of crows rendered as an Artboard List of `Crow` items ([lists](https://rive.app/docs/editor/data-binding/lists.md)), plus flock-wide celebrations. |
| `Splat` | later | A full-screen "gift on your windshield" overlay for the prank. |
In v1, the web page puts one `Crow` canvas per visible crow and lays them out with HTML and CSS. The Sentry and Elder are **variants** of `Crow`, not separate artboards, so the whole cast shares one rig.
In v1, the web page is a chat room: one animated `Crow` canvas per crow in the member roster (sidebar on desktop, a scrolling strip on phones), about 40 pixels. Message avatars are still images, not Rive. The Sentry and Elder are **variants** of `Crow`, not separate artboards, so the whole cast shares one rig.
Artboard spec: 256 × 256, transparent background, crow centered with its feet on a baseline 40 pixels from the bottom, so the crows line up in a row.
+19 -14
View File
@@ -9,26 +9,31 @@
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,500;0,700;1,500&family=IM+Fell+English+SC&display=swap" rel="stylesheet" />
</head>
<body>
<header class="top">
<h1>So Many Crows</h1>
<div class="app">
<aside class="side" aria-label="The flock">
<div class="brand">So Many Crows</div>
<h2>The flock</h2>
<div class="roster" id="roster"></div>
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
</aside>
<section class="room">
<header class="room-head">
<h1># todo</h1>
<p class="room-sub">Hand the flock a list. The Sentry sorts it, crows fly, you judge.</p>
</header>
<main>
<section class="perch" id="perch" aria-label="The flock"></section>
<p class="banner inked" id="banner" role="status" hidden></p>
<ol class="feed" id="feed" aria-live="polite"></ol>
<p class="typing" id="typing" aria-live="polite"></p>
<form class="composer" id="composer">
<label for="list">Your todo list, one item per line</label>
<div class="field inked"><textarea id="list" rows="5" placeholder="- [ ] What is the difference between a crow and a raven&#10;- [ ] Draft a tagline for So Many Crows"></textarea></div>
<button class="btn btn-go inked" id="send" type="submit">Send the flock</button>
<label class="sr-only" for="list">Your todo list, one item per line</label>
<div class="field inked"><textarea id="list" rows="3" placeholder="One item per line, e.g.&#10;What is the difference between a crow and a raven"></textarea></div>
<button class="btn btn-go inked" id="send" type="submit">Send</button>
<p class="hint">Ctrl or ⌘ + Enter to send</p>
</form>
<ol class="cards" id="cards" aria-live="polite"></ol>
<p class="meter" id="meter" hidden></p>
</main>
<div class="toast inked" id="toast" role="alert" hidden></div>
</section>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+10
View File
@@ -28,6 +28,16 @@ const STAR = `<path d="M12 1.8 L14.9 8.6 L22.2 9.2 L16.6 14 L18.3 21.2 L12 17.4
const REACTION_MS: Record<Reaction, number> = { cheer: 800, sulk: 800, levelUp: 2000, levelDown: 1000, prank: 2500 };
// A still crow for message avatars. The live, animated crows live in the roster.
export function crowAvatar(role: Role): SVGSVGElement {
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 352.23 324");
svg.setAttribute("class", `avatar crow-art role-${role}`);
svg.setAttribute("aria-hidden", "true");
svg.innerHTML = ART;
return svg;
}
export function svgCrow(name: string, role: Role): CrowView {
const el = document.createElement("figure");
el.className = `crow role-${role} pose-perched`;
+161 -115
View File
@@ -1,18 +1,16 @@
import "./style.css";
import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api";
import { svgCrow, type CrowView, type Role } from "./crow";
import { crowAvatar, 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 roster = $<HTMLDivElement>("roster");
const feed = $<HTMLOListElement>("feed");
const typing = $<HTMLParagraphElement>("typing");
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; } },
@@ -20,8 +18,18 @@ const store = {
};
requesterInput.value = store.get("requester") ?? "";
requesterInput.addEventListener("change", () => store.set("requester", requesterInput.value.trim()));
listInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
});
// ---------- the flock on screen ----------
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;
}
// ---------- the roster: live, animated crows ----------
const crows = new Map<string, CrowView>();
let trust: TrustLevels = new Map();
@@ -34,7 +42,7 @@ function crow(name: string): CrowView {
if (!c) {
c = svgCrow(name, roleOf(name));
crows.set(name, c);
perch.append(c.el);
roster.append(c.el);
}
return c;
}
@@ -45,36 +53,72 @@ function showTrust(name: string, taskType: string) {
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>();
// ---------- the feed: messages from you, the Sentry, the crows ----------
type Msg = { li: HTMLLIElement; body: HTMLDivElement; actions: HTMLDivElement; reactions: HTMLDivElement };
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;
const ROLE_TAG: Partial<Record<Role, string>> = { sentry: "sorts the list", elder: "merges reports" };
function fmtTime(ts?: string) {
const d = ts ? new Date(ts) : new Date();
return isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
function addCard(itemId: string, text: string) {
const li = el("li", "card inked");
li.dataset.state = "waiting";
const head = el("div", "card-head");
head.append(el("span", "dot"), el("p", "text", text));
function append(li: HTMLLIElement) {
const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
feed.append(li);
if (stick) feed.scrollTop = feed.scrollHeight;
}
function post(author: string, parts: Node[], ts?: string): Msg {
const li = el("li", "msg");
li.dataset.author = author;
const last = feed.lastElementChild as HTMLElement | null;
if (last?.dataset.author === author) li.classList.add("cont");
let avatar: Element;
if (author === "you") {
const who = requesterInput.value.trim() || "you";
avatar = el("span", "avatar you", who.charAt(0).toUpperCase());
} else {
avatar = crowAvatar(roleOf(author));
}
const head = el("div", "msg-head");
head.append(el("span", "author", author === "you" ? requesterInput.value.trim() || "You" : author));
const tag = ROLE_TAG[roleOf(author)];
if (author !== "you" && tag) head.append(el("span", "tag", tag));
const time = el("time", "", fmtTime(ts));
head.append(time);
const body = el("div", "msg-body");
body.append(...parts);
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 });
const reactions = el("div", "reactions");
const main = el("div", "msg-main");
main.append(head, body, actions, reactions);
li.append(avatar, main);
append(li);
return { li, body, actions, reactions };
}
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?");
function note(text: string, kind = "") {
const li = el("li", `msg system ${kind}`.trim());
li.append(el("p", "", text));
append(li);
}
function re(text: string) {
return el("p", "re", `re: ${text}`);
}
function react(m: Msg, text: string, kind: string) {
m.reactions.append(el("span", `reaction ${kind}`, text));
}
// ---------- per-item state ----------
type Item = { text: string; crows: string[]; taskType: string; triage?: Msg; answer?: Msg };
const items = new Map<string, Item>();
function ask(m: Msg, itemId: string, target: "triage" | "output") {
m.li.classList.add("asking");
m.actions.replaceChildren();
const yes = el("button", "btn btn-go inked", target === "triage" ? "Fly it" : "Looks good");
const no = el("button", "btn btn-no inked", target === "triage" ? "Not like that" : "Not right");
const err = el("p", "err");
@@ -91,141 +135,145 @@ function ask(itemId: string, target: "triage" | "output") {
}
});
}
const row = el("div", "buttons");
row.append(no, yes);
card.actions.append(q, row, err);
yes.focus({ preventScroll: true });
m.actions.append(yes, no, err);
}
// ---------- who's flying ----------
const flying = new Set<string>();
function renderTyping() {
const names = [...flying];
if (names.length === 0) { typing.textContent = ""; return; }
const who = names.length === 1 ? names[0]
: names.length === 2 ? `${names[0]} and ${names[1]}`
: `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
typing.textContent = `${who} ${names.length === 1 ? "is" : "are"} flying…`;
}
// ---------- events (mapping from RIVE_CONTRACT.md section 3) ----------
type Payload = Record<string, any>;
let source: EventSource | null = null;
const LEVELS = ["stranger", "regular", "trusted"];
const handlers: Record<string, (d: Payload) => void> = {
"list.started": (d) => {
const ol = el("ol", "list");
for (const it of d.items ?? []) {
items.set(it.item_id, { text: it.text, crows: [], taskType: "other" });
ol.append(el("li", "", it.text));
}
post("you", [ol], d.ts);
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)));
const it = items.get(d.item_id);
if (!it) return;
it.taskType = d.task_type;
const parts: Node[] = [re(it.text), el("p", "plan", `${d.mode} · ${d.task_type}`)];
if (d.reason) parts.push(el("p", "reason", d.reason));
if (d.subtasks?.length) {
const ul = el("ul");
for (const s of d.subtasks) ul.append(el("li", "", s));
parts.push(ul);
}
it.triage = post("Sentry", parts, d.ts);
showTrust("Sentry", d.task_type);
if (d.needs_approval) {
crow("Sentry").setPose("waiting");
ask(d.item_id, "triage");
ask(it.triage, 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);
}
const it = items.get(d.item_id);
if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
crow(d.crow).setPose("flying");
flying.add(d.crow);
renderTyping();
},
"crow.landed": (d) => crow(d.crow).setPose(d.ok ? "perched" : "grounded"),
"crow.landed": (d) => {
crow(d.crow).setPose(d.ok ? "perched" : "grounded");
flying.delete(d.crow);
renderTyping();
},
"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(", ")));
const it = items.get(d.item_id);
if (!it) return;
it.crows = d.crows ?? [];
const author = it.crows.includes("Elder") ? "Elder" : it.crows[0] ?? "Elder";
const parts: Node[] = [re(it.text), el("p", "answer", d.answer)];
const helpers = it.crows.filter((n) => n !== "Elder");
if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${helpers.join(", ")}`));
it.answer = post(author, parts, d.ts);
if (d.needs_approval) {
for (const n of card.crows) crow(n).setPose("waiting");
ask(d.item_id, "output");
for (const n of it.crows) crow(n).setPose("waiting");
ask(it.answer, d.item_id, "output");
}
},
verdict: (d) => {
const card = items.get(d.item_id);
if (!card) return;
card.actions.replaceChildren();
const it = items.get(d.item_id);
if (!it) return;
const m = d.target === "triage" ? it.triage : it.answer;
const good = d.verdict === "approved" || d.verdict === "auto";
const who = d.target === "triage" ? ["Sentry"] : card.crows;
if (m) {
m.li.classList.remove("asking");
m.actions.replaceChildren();
const label: Record<string, [string, string]> = {
approved: ["✓ Approved", "good"], rejected: ["✕ Rejected", "bad"],
auto: ["✓ Trusted, no review", "auto"], timeout: ["⌛ Timed out", "bad"],
};
const [text, kind] = label[d.verdict] ?? [d.verdict, ""];
react(m, text, kind);
}
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
for (const n of who) {
for (const n of d.target === "triage" ? ["Sentry"] : it.crows) {
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"];
trust.set(`${d.holder}|${d.task_type}`, d.to);
const what = String(d.task_type).replace("triage/", "sorting ");
const up = d.to > d.from;
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}.`);
note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
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 ")}.`);
c.fire(up ? "levelUp" : "levelDown");
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
},
"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");
const it = items.get(d.item_id);
note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`);
for (const n of it?.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;
flying.clear();
renderTyping();
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`);
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;
// ---------- pranks: the Sentry says something, the send button counts down ----------
let cooldownTimer = 0;
const sendLabel = sendBtn.textContent ?? "Send the flock";
const sendLabel = sendBtn.textContent ?? "Send";
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);
post("Sentry", [el("p", "", message)]);
let left = Math.max(0, Math.round(cooldownS));
clearInterval(cooldownTimer);
const tick = () => {
@@ -236,7 +284,7 @@ function prank(message: string, cooldownS: number) {
return;
}
sendBtn.disabled = true;
sendBtn.textContent = `The flock is ignoring you · ${left--}s`;
sendBtn.textContent = `${left--}s`;
};
tick();
cooldownTimer = window.setInterval(tick, 1000);
@@ -246,7 +294,7 @@ function prank(message: string, cooldownS: number) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = listInput.value.trim();
if (!text) { listInput.focus(); return; }
if (!text || sendBtn.disabled) { listInput.focus(); return; }
const requester = requesterInput.value.trim() || "anonymous";
store.set("requester", requester);
sendBtn.disabled = true;
@@ -256,16 +304,13 @@ form.addEventListener("submit", async (e) => {
if (res.pranked) {
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
} else {
showBanner(res.message);
note(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;
listInput.value = "";
trust = await getTrust();
source?.close();
source = listEvents(res.listId);
@@ -276,5 +321,6 @@ form.addEventListener("submit", async (e) => {
}
});
// The Sentry is always on watch.
// The Sentry is always on watch, and says hello.
crow("Sentry");
post("Sentry", [el("p", "", "Caw. Send me a todo list, one item per line. I'll sort it, the flock will fly it, and you judge the results.")]);
+151 -124
View File
@@ -1,6 +1,6 @@
/* Art direction: pen and ink on cream.
Thin, slightly wobbly ink lines; shading is stipple (dots), dense near
the edge and fading out. Color stays out of the way: the crows bring it. */
/* Art direction: pen and ink on cream. Ink linework and type, stipple shading,
no outlines on boxes. Layout: a chat room. The flock is the member list; the
Sentry and crows post into one channel; you judge with inline buttons. */
:root {
color-scheme: light;
--paper: #f4eedf;
@@ -21,122 +21,201 @@
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 500 18px/1.45 var(--body);
}
main { max-width: 680px; margin: 0 auto; padding: 0 16px 64px; }
html, body { height: 100%; }
body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.45 var(--body); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* Shared recipe: a hand-inked panel with a stippled shadow behind it.
::before is the paper and the pen line; ::after is the dot shading. */
/* Shared recipe: a filled panel with a stippled shadow. No pen lines. */
.inked { position: relative; isolation: isolate; background: none; border: 0; }
.inked::before, .inked::after { content: ""; position: absolute; border-radius: var(--corner); pointer-events: none; }
.inked::before { inset: 0; z-index: -1; background: var(--card); } /* no pen line: shape comes from fill and stipple */
.inked::before { inset: 0; z-index: -1; background: var(--card); }
.inked::after {
inset: 7px -7px -7px 7px; z-index: -2; background: var(--stipple);
inset: 6px -6px -6px 6px; z-index: -2; background: var(--stipple);
-webkit-mask-image: linear-gradient(135deg, transparent 30%, #000 90%);
mask-image: linear-gradient(135deg, transparent 30%, #000 90%);
}
/* ---------- header ---------- */
.top {
display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: flex-end; justify-content: space-between;
max-width: 680px; margin: 0 auto; padding: 22px 16px 10px;
}
.top h1 { margin: 0; font: 400 34px/1 var(--display); position: relative; }
.who { color: var(--muted); font-style: italic; }
.who input {
margin-left: 6px; width: 140px; padding: 2px 4px; font: 500 18px var(--body); color: var(--ink);
background: transparent; border: 0; border-bottom: var(--pen);
}
.who input:focus { outline: none; border-bottom-width: 3px; }
/* ---------- buttons ---------- */
.btn {
font: 400 18px/1 var(--display); letter-spacing: 0.5px; padding: 14px 20px; cursor: pointer;
font: 400 17px/1 var(--display); letter-spacing: 0.4px; padding: 12px 18px; cursor: pointer;
color: var(--ink); transition: transform 70ms;
}
.btn::after { inset: 5px -5px -5px 5px; }
.btn:active:not(:disabled) { transform: translate(4px, 4px); }
.btn::after { inset: 4px -4px -4px 4px; }
.btn:active:not(:disabled) { transform: translate(3px, 3px); }
.btn:active:not(:disabled)::after { opacity: 0; }
.btn:disabled { cursor: default; color: var(--muted); }
.btn:disabled::before { background: linear-gradient(rgb(249 245 234 / 0.72), rgb(249 245 234 / 0.72)), var(--stipple-tile) 0 0 / 64px 64px, var(--stipple-tile) 23px 11px / 47px 47px, var(--card); }
.btn:disabled::after { opacity: 0; }
.btn:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 5px; }
.btn:focus-visible::after, .composer .field:focus-within::after { -webkit-mask-image: none; mask-image: none; }
.btn:focus-visible::after { -webkit-mask-image: none; mask-image: none; }
.btn-go { color: var(--paper); }
.btn-go::before { background: var(--ink); }
.btn-no { color: var(--red); }
.btn-no::before { background: var(--paper); }
/* ---------- composer ---------- */
.composer { display: grid; gap: 10px; margin: 8px 0 28px; }
.composer label { font-style: italic; color: var(--muted); }
.composer .field { padding: 2px; }
.composer textarea {
display: block; width: 100%; padding: 12px 14px; font: 500 18px/1.4 var(--body); color: var(--ink);
resize: vertical; background: transparent; border: 0;
}
.composer textarea:focus { outline: none; }
/* ---------- app frame: sidebar + room ---------- */
.app { display: grid; grid-template-columns: 248px minmax(0, 1fr); height: 100vh; height: 100dvh; }
/* ---------- the perch ----------
Every crow stands on its own segment of one branch line; segments touch,
so each row reads as one branch. Names sit under the branch, all alike.
Each crow has a fixed 22px headroom row for marks ("?", sparkles). */
.perch { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 0; margin: 4px 0 26px; }
.crow { margin: 0; width: 76px; padding-top: 22px; display: grid; justify-items: center; position: relative; }
.side {
position: relative; z-index: 1; display: flex; flex-direction: column; gap: 12px;
padding: 18px 14px; background: var(--card); overflow-y: auto;
}
.side::after { /* stippled edge where the sidebar meets the room */
content: ""; position: absolute; top: 0; bottom: 0; right: -8px; width: 8px; background: var(--stipple); pointer-events: none;
-webkit-mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.7), transparent); mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.7), transparent);
}
.brand { font: 400 24px/1.1 var(--display); padding: 0 6px; }
.side h2 { margin: 8px 6px 0; font: italic 500 14px/1 var(--body); color: var(--muted); }
.who { margin-top: auto; padding: 0 6px; color: var(--muted); font-style: italic; font-size: 15px; }
.who input {
display: block; width: 100%; margin-top: 4px; padding: 2px 0; font: 500 17px var(--body); color: var(--ink);
background: transparent; border: 0; border-bottom: 2px solid var(--ink);
}
.who input:focus { outline: none; border-bottom-width: 3px; }
/* ---------- roster: live crows ---------- */
.roster { display: flex; flex-direction: column; gap: 2px; }
.crow { margin: 0; position: relative; }
.crow.role-sentry { order: -1; }
.crow.role-elder { order: 99; }
.crow-body { width: 72px; height: 66px; }
.crow .crow-body svg { width: 100%; height: 100%; overflow: visible; }
.crow .disc { fill: #b7e4c7; }
.crow.role-sentry .disc { fill: #ffe08a; }
.crow.role-elder .disc { fill: #d6dde6; }
.crow .bird { fill: #000; }
.roster .crow { display: grid; grid-template-columns: 40px 1fr 16px; align-items: center; gap: 10px; padding: 5px 6px; }
.roster .crow figcaption { order: 2; }
.roster .crow .badge { order: 3; }
.crow-body { width: 40px; height: 37px; }
.crow-body svg { display: block; width: 100%; height: 100%; overflow: visible; }
.crow figcaption { font: 400 16px/1.15 var(--display); min-width: 0; }
.crow figcaption::after { display: block; font: italic 500 13px/1.3 var(--body); color: var(--muted); content: "perched"; }
.pose-flying figcaption::after { content: "flying"; }
.pose-waiting figcaption::after { content: "waiting on you"; color: var(--red); }
.pose-grounded figcaption::after { content: "grounded"; }
.crow-art .disc, .crow .disc { fill: #b7e4c7; }
.role-sentry .disc { fill: #ffe08a; }
.role-elder .disc { fill: #d6dde6; }
.crow .bird, .crow-art .bird { fill: #000; }
.crow .jaw { transform-origin: 211px 182px; transition: transform 150ms; }
.crow figcaption {
justify-self: stretch; position: relative; margin-top: 0; padding-top: 6px;
border-top: 2px solid var(--ink); text-align: center; font: 400 15px/1.2 var(--display);
}
.crow figcaption::before {
content: ""; position: absolute; left: 0; right: 0; top: 0; height: 7px; background: var(--stipple);
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.6), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.6), transparent);
}
/* Trust star: its own corner, top right of the headroom, clear of the beak. */
.crow .badge {
position: absolute; top: 4px; right: 4px; width: 18px; height: 18px;
fill: var(--card); stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round;
width: 16px; height: 16px; fill: var(--card); stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round;
opacity: 0; transition: opacity 150ms;
}
.crow[data-trust="1"] .badge { opacity: 1; }
.crow[data-trust="2"] .badge { opacity: 1; fill: var(--ink); }
/* Body layer: poses */
/* Poses and reactions: motion only, crows never change size. */
.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: 0; left: 0; right: 0; text-align: center; font: 400 22px/1 var(--display);
animation: pop 1.4s ease-in-out infinite;
}
.pose-grounded .crow-body { opacity: 0.4; }
/* 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: -6px; left: calc(50% - 28px); width: 56px; height: 30px; animation: rise 2s ease-out forwards;
background: var(--sparkle) 16px 0 / 26px 26px no-repeat, var(--glint) 0 14px / 12px 12px no-repeat, var(--glint) 44px 10px / 10px 10px no-repeat;
content: ""; position: absolute; top: -8px; left: -4px; width: 56px; height: 30px; z-index: 2; pointer-events: none;
animation: rise 2s ease-out forwards;
background: var(--sparkle) 16px 0 / 22px 22px no-repeat, var(--glint) 0 12px / 10px 10px no-repeat, var(--glint) 42px 8px / 9px 9px no-repeat;
}
.r-levelDown .crow-body { animation: shrug 1s ease-out; }
.r-prank .crow-body { animation: prank 2.5s ease-in-out; }
@media (prefers-reduced-motion: reduce) {
.crow *, .crow::before, .crow::after { animation: none !important; transition: none !important; }
}
/* ---------- room: header, feed, composer ---------- */
.room { display: grid; grid-template-rows: auto 1fr auto auto; min-width: 0; min-height: 0; }
.room-head { position: relative; padding: 16px 24px 12px; }
.room-head h1 { margin: 0; font: 400 22px/1.1 var(--display); }
.room-sub { margin: 2px 0 0; color: var(--muted); font-style: italic; font-size: 15px; }
.room-head::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -7px; height: 7px; background: var(--stipple); z-index: 1;
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent);
}
.feed { list-style: none; margin: 0; padding: 14px 0 6px; overflow-y: auto; min-height: 0; }
.msg { display: grid; grid-template-columns: 40px 1fr; gap: 0 12px; padding: 8px 24px; }
@media (hover: hover) { .msg:hover { background: rgb(28 25 21 / 0.03); } }
/* Consecutive messages from one author group under a single name, Slack style. */
.msg.cont { padding-top: 0; }
.msg.cont > .avatar { visibility: hidden; height: 0; }
.msg.cont .msg-head { display: none; }
.msg.asking { background: rgb(155 47 34 / 0.05); }
.avatar { width: 40px; height: 37px; margin-top: 3px; }
.avatar.you {
display: grid; place-items: center; width: 36px; height: 36px; margin: 3px 2px 0; border-radius: 50%;
background: var(--ink); color: var(--paper); font: 400 18px/1 var(--display);
}
.msg-main { min-width: 0; }
.msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; }
.author { font: 400 17px/1.2 var(--display); }
.tag { font: italic 500 13px/1 var(--body); color: var(--muted); }
.msg-head time { font-size: 13px; color: var(--muted); }
.msg-body p { margin: 2px 0; }
.msg-body ol, .msg-body ul { margin: 4px 0; padding-left: 22px; }
.re { font-style: italic; color: var(--muted); font-size: 15px; }
.plan { font: 400 15px/1.3 var(--display); }
.reason, .with { color: var(--muted); font-style: italic; }
.answer { white-space: pre-wrap; overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 4px; }
.actions:empty { display: none; }
.actions .btn { padding: 9px 16px; font-size: 15px; }
.err { flex-basis: 100%; margin: 4px 0 0; color: var(--red); font-size: 15px; }
.err:empty { display: none; }
.reactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.reactions:empty { display: none; }
.reaction { padding: 1px 10px; border-radius: 999px; background: #ebe3cf; font-size: 14px; }
.reaction.good, .reaction.auto { color: var(--ink); }
.reaction.bad { color: var(--red); }
.msg.system { display: block; padding: 6px 24px; text-align: center; color: var(--muted); font-style: italic; font-size: 15px; }
.msg.system p { margin: 0; }
.msg.system.sparkle p::before {
content: ""; display: inline-block; width: 15px; height: 15px; margin-right: 6px; vertical-align: -2px;
background: var(--sparkle) center / contain no-repeat;
}
.typing { margin: 0; min-height: 24px; padding: 2px 24px; color: var(--muted); font-style: italic; font-size: 14px; }
.composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 6px 12px; padding: 4px 24px 16px; }
.composer .field { padding: 2px; }
.composer textarea {
display: block; width: 100%; max-height: 40vh; padding: 10px 12px; font: 500 17px/1.4 var(--body); color: var(--ink);
resize: vertical; background: transparent; border: 0;
}
.composer textarea:focus { outline: none; }
.composer .field:focus-within::after { -webkit-mask-image: none; mask-image: none; }
.hint { grid-column: 1 / -1; margin: 0; color: var(--muted); font-style: italic; font-size: 13px; }
/* ---------- phones: sidebar becomes a top bar with a scrolling flock ---------- */
@media (max-width: 759px) {
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.side { min-width: 0; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 10px 12px 6px; overflow: visible; }
.side::after { top: auto; bottom: -8px; left: 0; right: 0; width: auto; height: 8px;
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.7), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.7), transparent); }
.side h2 { display: none; }
.brand { font-size: 20px; padding: 0; }
.who { order: 2; margin: 0 0 0 auto; padding: 0; font-size: 14px; }
.who input { display: inline-block; width: 96px; margin: 0 0 0 4px; font-size: 15px; }
.roster { order: 3; width: 100%; min-width: 0; flex-direction: row; overflow-x: auto; gap: 2px; padding-top: 10px; }
.roster .crow { display: flex; flex-direction: column; align-items: center; flex: none; width: 54px; padding: 0 2px; }
.roster .crow-body { width: 38px; height: 35px; }
.roster .crow figcaption { font-size: 12px; }
.roster .crow figcaption::after { display: none; }
.roster .crow .badge { position: absolute; top: -4px; right: 3px; width: 13px; height: 13px; }
.roster .pose-waiting::after { content: "?"; position: absolute; top: -10px; left: 4px; font: 400 15px/1 var(--display); color: var(--red); }
.room-head { padding: 12px 14px 10px; }
.room-head h1 { font-size: 19px; }
.room-sub { display: none; }
.msg { padding: 8px 14px; gap: 0 10px; grid-template-columns: 34px 1fr; }
.avatar { width: 34px; height: 31px; }
.avatar.you { width: 30px; height: 30px; font-size: 15px; }
.msg.system { padding: 6px 14px; }
.typing { padding: 2px 14px; }
.composer { padding: 4px 12px 12px; }
.hint { display: none; }
}
@keyframes breathe { 50% { transform: translateY(-2px); } }
@keyframes flap { from { transform: translateY(0) rotate(-3deg); } to { transform: translateY(-8px) rotate(3deg); } }
@@ -151,55 +230,3 @@ main { max-width: 680px; margin: 0 auto; padding: 0 16px 64px; }
@keyframes rise { from { opacity: 1; transform: translateY(0) scale(0.6); } to { opacity: 0; transform: translateY(-30px) scale(1.4); } }
@keyframes shrug { 30% { transform: rotate(-5deg); } 60% { transform: rotate(5deg); } }
@keyframes prank { 25% { transform: translate(-120px, -60px) rotate(-30deg); opacity: 0; } 55% { transform: translate(120px, -60px); opacity: 0; } 80% { transform: none; 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: 22px; }
.card { padding: 14px 18px; }
.card::before { border: 0; }
.card::after {
inset: auto -2px -10px 10px; height: 60%;
-webkit-mask-image: linear-gradient(to bottom, transparent, rgb(0 0 0 / 0.55) 85%, #000);
mask-image: linear-gradient(to bottom, transparent, rgb(0 0 0 / 0.55) 85%, #000);
}
.card-head { display: flex; gap: 10px; align-items: flex-start; }
.card .text { margin: 0; font-weight: 700; }
.dot { flex: none; width: 14px; height: 14px; margin-top: 6px; border-radius: 50%; border: 1.5px solid var(--ink); }
.card[data-state="flying"] .dot { background: var(--stipple); animation: pop 0.8s infinite; }
.card[data-state="asking"] .dot { background: var(--ink); animation: pop 1.2s infinite; }
.card[data-state="asking"]::after { height: 85%; -webkit-mask-image: linear-gradient(to bottom, transparent, #000 70%); mask-image: linear-gradient(to bottom, transparent, #000 70%); }
.card[data-state="done"] .dot { background: var(--ink); }
.card[data-state="rejected"] .dot, .card[data-state="skipped"] .dot { background: none; }
.card[data-state="skipped"] .text { text-decoration: line-through; color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 6px 0 0 24px; }
.chips:empty { display: none; }
.chip { font: 400 15px/1.3 var(--display); border-bottom: 1.5px solid var(--ink); }
.chip.mode-murder { border-bottom-width: 3px; }
.chip.mode-split { border-bottom-style: double; border-bottom-width: 4px; }
.chip.crows { font: italic 500 15px/1.3 var(--body); color: var(--muted); border-bottom: 0; }
.reason { margin: 6px 0 0 24px; color: var(--muted); font-style: italic; }
.subtasks { margin: 6px 0 0 24px; padding-left: 18px; }
.subtasks:empty { display: none; }
.answer {
margin: 12px 0 0 24px; padding: 4px 0 4px 14px; border-left: 1.5px solid var(--ink);
white-space: pre-wrap; overflow-wrap: anywhere;
}
.actions:empty { display: none; }
.actions { margin: 14px 0 6px 24px; }
.question { margin: 0 0 12px; font: 400 19px/1.3 var(--display); }
.buttons { display: flex; gap: 14px; }
.buttons .btn { flex: 1; }
.note { margin: 0; color: var(--muted); font-style: italic; }
.err { margin: 8px 0 0; color: var(--red); }
.err:empty { display: none; }
.banner { margin: 0 0 20px; padding: 10px 16px; font: 400 18px/1.3 var(--display); text-align: center; }
.meter { margin: 22px 0 0; text-align: center; color: var(--muted); font-style: italic; }
.toast {
position: fixed; left: 16px; right: 16px; bottom: 20px; max-width: 520px; margin: 0 auto;
padding: 14px 18px; font: 400 18px/1.35 var(--display); text-align: center;
}