- 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
83 lines
3.8 KiB
TypeScript
83 lines
3.8 KiB
TypeScript
// 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>`;
|
|
|
|
// Trust badge: a pen-and-ink star. Outline at "regular", inked in at "trusted".
|
|
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 L5.7 21.2 L7.4 14 L1.8 9.2 L9.1 8.6 Z"/>`;
|
|
|
|
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`;
|
|
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.createElementNS(NS, "svg");
|
|
badge.setAttribute("class", "badge");
|
|
badge.setAttribute("viewBox", "0 0 24 24");
|
|
badge.setAttribute("aria-hidden", "true");
|
|
badge.innerHTML = STAR;
|
|
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]);
|
|
},
|
|
};
|
|
}
|