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
+346 -258

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. 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 ## 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. | | `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. | | `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. 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.
+22 -17
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" /> <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> </head>
<body> <body>
<header class="top"> <div class="app">
<h1>So Many Crows</h1> <aside class="side" aria-label="The flock">
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label> <div class="brand">So Many Crows</div>
</header> <h2>The flock</h2>
<div class="roster" id="roster"></div>
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
</aside>
<main> <section class="room">
<section class="perch" id="perch" aria-label="The flock"></section> <header class="room-head">
<p class="banner inked" id="banner" role="status" hidden></p> <h1># todo</h1>
<p class="room-sub">Hand the flock a list. The Sentry sorts it, crows fly, you judge.</p>
</header>
<form class="composer" id="composer"> <ol class="feed" id="feed" aria-live="polite"></ol>
<label for="list">Your todo list, one item per line</label> <p class="typing" id="typing" aria-live="polite"></p>
<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>
</form>
<ol class="cards" id="cards" aria-live="polite"></ol> <form class="composer" id="composer">
<p class="meter" id="meter" hidden></p> <label class="sr-only" for="list">Your todo list, one item per line</label>
</main> <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>
<div class="toast inked" id="toast" role="alert" hidden></div> <p class="hint">Ctrl or ⌘ + Enter to send</p>
</form>
</section>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </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 }; 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 { export function svgCrow(name: string, role: Role): CrowView {
const el = document.createElement("figure"); const el = document.createElement("figure");
el.className = `crow role-${role} pose-perched`; el.className = `crow role-${role} pose-perched`;
+161 -115
View File
@@ -1,18 +1,16 @@
import "./style.css"; import "./style.css";
import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api"; 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 ---------- // ---------- page ----------
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const perch = $<HTMLElement>("perch"); const roster = $<HTMLDivElement>("roster");
const cards = $<HTMLOListElement>("cards"); const feed = $<HTMLOListElement>("feed");
const typing = $<HTMLParagraphElement>("typing");
const form = $<HTMLFormElement>("composer"); const form = $<HTMLFormElement>("composer");
const listInput = $<HTMLTextAreaElement>("list"); const listInput = $<HTMLTextAreaElement>("list");
const sendBtn = $<HTMLButtonElement>("send"); const sendBtn = $<HTMLButtonElement>("send");
const requesterInput = $<HTMLInputElement>("requester"); const requesterInput = $<HTMLInputElement>("requester");
const banner = $<HTMLParagraphElement>("banner");
const toast = $<HTMLDivElement>("toast");
const meter = $<HTMLParagraphElement>("meter");
const store = { const store = {
get(k: string) { try { return localStorage.getItem(k); } catch { return null; } }, get(k: string) { try { return localStorage.getItem(k); } catch { return null; } },
@@ -20,8 +18,18 @@ const store = {
}; };
requesterInput.value = store.get("requester") ?? ""; requesterInput.value = store.get("requester") ?? "";
requesterInput.addEventListener("change", () => store.set("requester", requesterInput.value.trim())); 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>(); const crows = new Map<string, CrowView>();
let trust: TrustLevels = new Map(); let trust: TrustLevels = new Map();
@@ -34,7 +42,7 @@ function crow(name: string): CrowView {
if (!c) { if (!c) {
c = svgCrow(name, roleOf(name)); c = svgCrow(name, roleOf(name));
crows.set(name, c); crows.set(name, c);
perch.append(c.el); roster.append(c.el);
} }
return c; return c;
} }
@@ -45,36 +53,72 @@ function showTrust(name: string, taskType: string) {
crow(name).setTrust(trust.get(key) ?? 0); crow(name).setTrust(trust.get(key) ?? 0);
} }
// ---------- item cards ---------- // ---------- the feed: messages from you, the Sentry, the crows ----------
type Card = { el: HTMLLIElement; crows: string[]; taskType: string; actions: HTMLDivElement }; type Msg = { li: HTMLLIElement; body: HTMLDivElement; actions: HTMLDivElement; reactions: HTMLDivElement };
const items = new Map<string, Card>();
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string) { const ROLE_TAG: Partial<Record<Role, string>> = { sentry: "sorts the list", elder: "merges reports" };
const e = document.createElement(tag);
if (cls) e.className = cls; function fmtTime(ts?: string) {
if (text !== undefined) e.textContent = text; // server text always goes in as text, never HTML const d = ts ? new Date(ts) : new Date();
return e; return isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
} }
function addCard(itemId: string, text: string) { function append(li: HTMLLIElement) {
const li = el("li", "card inked"); const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
li.dataset.state = "waiting"; feed.append(li);
const head = el("div", "card-head"); if (stick) feed.scrollTop = feed.scrollHeight;
head.append(el("span", "dot"), el("p", "text", text)); }
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"); const actions = el("div", "actions");
li.append(head, el("div", "chips"), el("p", "reason"), el("ul", "subtasks"), el("blockquote", "answer"), actions); const reactions = el("div", "reactions");
li.querySelector<HTMLElement>(".reason")!.hidden = true; const main = el("div", "msg-main");
li.querySelector<HTMLElement>(".answer")!.hidden = true; main.append(head, body, actions, reactions);
cards.append(li); li.append(avatar, main);
items.set(itemId, { el: li, crows: [], taskType: "other", actions }); append(li);
return { li, body, actions, reactions };
} }
function ask(itemId: string, target: "triage" | "output") { function note(text: string, kind = "") {
const card = items.get(itemId); const li = el("li", `msg system ${kind}`.trim());
if (!card) return; li.append(el("p", "", text));
card.el.dataset.state = "asking"; append(li);
card.actions.replaceChildren(); }
const q = el("p", "question", target === "triage" ? "Approve the Sentry's plan?" : "Accept this answer?");
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 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 no = el("button", "btn btn-no inked", target === "triage" ? "Not like that" : "Not right");
const err = el("p", "err"); const err = el("p", "err");
@@ -91,141 +135,145 @@ function ask(itemId: string, target: "triage" | "output") {
} }
}); });
} }
const row = el("div", "buttons"); m.actions.append(yes, no, err);
row.append(no, yes); }
card.actions.append(q, row, err);
yes.focus({ preventScroll: true }); // ---------- 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) ---------- // ---------- events (mapping from RIVE_CONTRACT.md section 3) ----------
type Payload = Record<string, any>; type Payload = Record<string, any>;
let source: EventSource | null = null; let source: EventSource | null = null;
const LEVELS = ["stranger", "regular", "trusted"];
const handlers: Record<string, (d: Payload) => void> = { const handlers: Record<string, (d: Payload) => void> = {
"list.started": (d) => { "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"); crow("Sentry").setPose("perched");
for (const it of d.items ?? []) addCard(it.item_id, it.text);
}, },
"item.triaged": (d) => { "item.triaged": (d) => {
const card = items.get(d.item_id); const it = items.get(d.item_id);
if (!card) return; if (!it) return;
card.taskType = d.task_type; it.taskType = d.task_type;
const chips = card.el.querySelector(".chips")!; const parts: Node[] = [re(it.text), el("p", "plan", `${d.mode} · ${d.task_type}`)];
chips.replaceChildren(el("span", `chip mode-${d.mode}`, d.mode), el("span", "chip", d.task_type)); if (d.reason) parts.push(el("p", "reason", d.reason));
const reason = card.el.querySelector<HTMLElement>(".reason")!; if (d.subtasks?.length) {
reason.textContent = d.reason; const ul = el("ul");
reason.hidden = !d.reason; for (const s of d.subtasks) ul.append(el("li", "", s));
card.el.querySelector(".subtasks")!.replaceChildren(...(d.subtasks ?? []).map((s: string) => el("li", "", s))); parts.push(ul);
}
it.triage = post("Sentry", parts, d.ts);
showTrust("Sentry", d.task_type); showTrust("Sentry", d.task_type);
if (d.needs_approval) { if (d.needs_approval) {
crow("Sentry").setPose("waiting"); crow("Sentry").setPose("waiting");
ask(d.item_id, "triage"); ask(it.triage, d.item_id, "triage");
} }
}, },
"crow.flying": (d) => { "crow.flying": (d) => {
const card = items.get(d.item_id); const it = items.get(d.item_id);
if (card) { if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
card.el.dataset.state = "flying";
if (d.crow !== "Sentry") showTrust(d.crow, card.taskType);
}
crow(d.crow).setPose("flying"); 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) => { "item.answered": (d) => {
const card = items.get(d.item_id); const it = items.get(d.item_id);
if (!card) return; if (!it) return;
card.crows = d.crows ?? []; it.crows = d.crows ?? [];
const answer = card.el.querySelector<HTMLElement>(".answer")!; const author = it.crows.includes("Elder") ? "Elder" : it.crows[0] ?? "Elder";
answer.textContent = d.answer; const parts: Node[] = [re(it.text), el("p", "answer", d.answer)];
answer.hidden = false; const helpers = it.crows.filter((n) => n !== "Elder");
const chips = card.el.querySelector(".chips")!; if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${helpers.join(", ")}`));
chips.append(el("span", "chip crows", card.crows.join(", "))); it.answer = post(author, parts, d.ts);
if (d.needs_approval) { if (d.needs_approval) {
for (const n of card.crows) crow(n).setPose("waiting"); for (const n of it.crows) crow(n).setPose("waiting");
ask(d.item_id, "output"); ask(it.answer, d.item_id, "output");
} }
}, },
verdict: (d) => { verdict: (d) => {
const card = items.get(d.item_id); const it = items.get(d.item_id);
if (!card) return; if (!it) return;
card.actions.replaceChildren(); const m = d.target === "triage" ? it.triage : it.answer;
const good = d.verdict === "approved" || d.verdict === "auto"; 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 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).fire(good ? "cheer" : "sulk");
crow(n).setPose("perched"); 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.changed": (d) => {
trust.set(d.holder === "Sentry" ? `Sentry|${d.task_type}` : `${d.holder}|${d.task_type}`, d.to); trust.set(`${d.holder}|${d.task_type}`, d.to);
const names = ["stranger", "regular", "trusted"]; const what = String(d.task_type).replace("triage/", "sorting ");
const up = d.to > d.from;
if (d.holder === "flock") { if (d.holder === "flock") {
showBanner(d.to > d.from note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
? `The flock earned "${names[d.to]}" on ${d.task_type}.`
: `The flock dropped to "${names[d.to]}" on ${d.task_type}.`);
return; return;
} }
const c = crow(d.holder); const c = crow(d.holder);
c.setTrust(d.to); c.setTrust(d.to);
c.fire(d.to > d.from ? "levelUp" : "levelDown"); c.fire(up ? "levelUp" : "levelDown");
if (d.to > d.from) showBanner(`${d.holder} leveled up: ${names[d.to]} on ${String(d.task_type).replace("triage/", "triage of ")}.`); note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
}, },
"item.skipped": (d) => { "item.skipped": (d) => {
const card = items.get(d.item_id); const it = items.get(d.item_id);
if (!card) return; note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`);
card.el.dataset.state = "skipped"; for (const n of it?.crows ?? []) crow(n).setPose("perched");
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), "requester.pranked": (d) => prank(d.message, d.cooldown_s),
"list.finished": (d) => { "list.finished": (d) => {
for (const c of crows.values()) { c.setPose("perched"); c.fire("cheer"); } 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.`; flying.clear();
meter.hidden = false; renderTyping();
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`);
source?.close(); source?.close();
source = null; source = null;
sendBtn.disabled = false; sendBtn.disabled = false;
}, },
}; };
// ---------- toasts ---------- // ---------- pranks: the Sentry says something, the send button counts down ----------
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; let cooldownTimer = 0;
const sendLabel = sendBtn.textContent ?? "Send the flock"; const sendLabel = sendBtn.textContent ?? "Send";
function prank(message: string, cooldownS: number) { function prank(message: string, cooldownS: number) {
crow("Sentry").fire("prank"); crow("Sentry").fire("prank");
toast.textContent = message; post("Sentry", [el("p", "", message)]);
toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => (toast.hidden = true), 5000);
let left = Math.max(0, Math.round(cooldownS)); let left = Math.max(0, Math.round(cooldownS));
clearInterval(cooldownTimer); clearInterval(cooldownTimer);
const tick = () => { const tick = () => {
@@ -236,7 +284,7 @@ function prank(message: string, cooldownS: number) {
return; return;
} }
sendBtn.disabled = true; sendBtn.disabled = true;
sendBtn.textContent = `The flock is ignoring you · ${left--}s`; sendBtn.textContent = `${left--}s`;
}; };
tick(); tick();
cooldownTimer = window.setInterval(tick, 1000); cooldownTimer = window.setInterval(tick, 1000);
@@ -246,7 +294,7 @@ function prank(message: string, cooldownS: number) {
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const text = listInput.value.trim(); const text = listInput.value.trim();
if (!text) { listInput.focus(); return; } if (!text || sendBtn.disabled) { listInput.focus(); return; }
const requester = requesterInput.value.trim() || "anonymous"; const requester = requesterInput.value.trim() || "anonymous";
store.set("requester", requester); store.set("requester", requester);
sendBtn.disabled = true; sendBtn.disabled = true;
@@ -256,16 +304,13 @@ form.addEventListener("submit", async (e) => {
if (res.pranked) { if (res.pranked) {
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
} else { } else {
showBanner(res.message); note(res.message);
sendBtn.disabled = false; sendBtn.disabled = false;
} }
return; return;
} }
// Fresh list: clear the old cards, keep the crows (they remember you). listInput.value = "";
cards.replaceChildren();
items.clear();
meter.hidden = true;
trust = await getTrust(); trust = await getTrust();
source?.close(); source?.close();
source = listEvents(res.listId); 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"); 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. /* Art direction: pen and ink on cream. Ink linework and type, stipple shading,
Thin, slightly wobbly ink lines; shading is stipple (dots), dense near no outlines on boxes. Layout: a chat room. The flock is the member list; the
the edge and fading out. Color stays out of the way: the crows bring it. */ Sentry and crows post into one channel; you judge with inline buttons. */
:root { :root {
color-scheme: light; color-scheme: light;
--paper: #f4eedf; --paper: #f4eedf;
@@ -21,122 +21,201 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { html, body { height: 100%; }
margin: 0; body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.45 var(--body); }
background: var(--paper); .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
color: var(--ink);
font: 500 18px/1.45 var(--body);
}
main { max-width: 680px; margin: 0 auto; padding: 0 16px 64px; }
/* Shared recipe: a hand-inked panel with a stippled shadow behind it. /* Shared recipe: a filled panel with a stippled shadow. No pen lines. */
::before is the paper and the pen line; ::after is the dot shading. */
.inked { position: relative; isolation: isolate; background: none; border: 0; } .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, .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 { .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%); -webkit-mask-image: linear-gradient(135deg, transparent 30%, #000 90%);
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 ---------- */ /* ---------- buttons ---------- */
.btn { .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; color: var(--ink); transition: transform 70ms;
} }
.btn::after { inset: 5px -5px -5px 5px; } .btn::after { inset: 4px -4px -4px 4px; }
.btn:active:not(:disabled) { transform: translate(4px, 4px); } .btn:active:not(:disabled) { transform: translate(3px, 3px); }
.btn:active:not(:disabled)::after { opacity: 0; } .btn:active:not(:disabled)::after { opacity: 0; }
.btn:disabled { cursor: default; color: var(--muted); } .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::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:disabled::after { opacity: 0; }
.btn:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 5px; } .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 { color: var(--paper); }
.btn-go::before { background: var(--ink); } .btn-go::before { background: var(--ink); }
.btn-no { color: var(--red); } .btn-no { color: var(--red); }
.btn-no::before { background: var(--paper); } .btn-no::before { background: var(--paper); }
/* ---------- composer ---------- */ /* ---------- app frame: sidebar + room ---------- */
.composer { display: grid; gap: 10px; margin: 8px 0 28px; } .app { display: grid; grid-template-columns: 248px minmax(0, 1fr); height: 100vh; height: 100dvh; }
.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; }
/* ---------- the perch ---------- .side {
Every crow stands on its own segment of one branch line; segments touch, position: relative; z-index: 1; display: flex; flex-direction: column; gap: 12px;
so each row reads as one branch. Names sit under the branch, all alike. padding: 18px 14px; background: var(--card); overflow-y: auto;
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; } .side::after { /* stippled edge where the sidebar meets the room */
.crow { margin: 0; width: 76px; padding-top: 22px; display: grid; justify-items: center; position: relative; } 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-sentry { order: -1; }
.crow.role-elder { order: 99; } .crow.role-elder { order: 99; }
.crow-body { width: 72px; height: 66px; } .roster .crow { display: grid; grid-template-columns: 40px 1fr 16px; align-items: center; gap: 10px; padding: 5px 6px; }
.crow .crow-body svg { width: 100%; height: 100%; overflow: visible; } .roster .crow figcaption { order: 2; }
.crow .disc { fill: #b7e4c7; } .roster .crow .badge { order: 3; }
.crow.role-sentry .disc { fill: #ffe08a; } .crow-body { width: 40px; height: 37px; }
.crow.role-elder .disc { fill: #d6dde6; } .crow-body svg { display: block; width: 100%; height: 100%; overflow: visible; }
.crow .bird { fill: #000; } .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 .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 { .crow .badge {
position: absolute; top: 4px; right: 4px; width: 18px; height: 18px; width: 16px; height: 16px; fill: var(--card); stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round;
fill: var(--card); stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round;
opacity: 0; transition: opacity 150ms; opacity: 0; transition: opacity 150ms;
} }
.crow[data-trust="1"] .badge { opacity: 1; } .crow[data-trust="1"] .badge { opacity: 1; }
.crow[data-trust="2"] .badge { opacity: 1; fill: var(--ink); } .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-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 .crow-body { animation: flap 0.5s ease-in-out infinite alternate; }
.pose-flying .jaw { transform: rotate(-10deg); } .pose-flying .jaw { transform: rotate(-10deg); }
.pose-waiting .crow-body { animation: tilt 1.4s ease-in-out infinite; } .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; } .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 .crow-body { animation: hop 0.8s ease-out; }
.r-cheer .jaw { transform: rotate(14deg); } .r-cheer .jaw { transform: rotate(14deg); }
.r-sulk .crow-body { animation: droop 0.8s ease-out; } .r-sulk .crow-body { animation: droop 0.8s ease-out; }
.r-levelUp .crow-body { animation: levelup 2s ease-out; } .r-levelUp .crow-body { animation: levelup 2s ease-out; }
.r-levelUp .jaw { transform: rotate(16deg); } .r-levelUp .jaw { transform: rotate(16deg); }
.r-levelUp::before { .r-levelUp::before {
content: ""; position: absolute; top: -6px; left: calc(50% - 28px); width: 56px; height: 30px; animation: rise 2s ease-out forwards; content: ""; position: absolute; top: -8px; left: -4px; width: 56px; height: 30px; z-index: 2; pointer-events: none;
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; 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-levelDown .crow-body { animation: shrug 1s ease-out; }
.r-prank .crow-body { animation: prank 2.5s ease-in-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 breathe { 50% { transform: translateY(-2px); } }
@keyframes flap { from { transform: translateY(0) rotate(-3deg); } to { transform: translateY(-8px) rotate(3deg); } } @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 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 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; } } @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;
}