Flock as list tiles with latest updates

- Each crow is a tile: live avatar, name, latest update, time;
  the most recently updated tile moves to the top
- Red "N waiting" count per tile tracks open questions separately
  from the latest update, so a newer update never hides one
- Phones: no crow strip; two tabs (# todo, Flock) with a count of
  crows waiting on you

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:41:22 -06:00
1 parent 0733ba1d9c
commit 68ab73f957
3 files changed
+147 -41

No files matched your search

+93 -12
View File
@@ -29,24 +29,71 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?:
return e;
}
// ---------- the roster: live, animated crows ----------
const crows = new Map<string, CrowView>();
// ---------- the roster: one list tile per crow, latest update first ----------
type Tile = { view: CrowView; el: HTMLDivElement; update: HTMLParagraphElement; time: HTMLTimeElement; count: HTMLSpanElement; pending: Set<string> };
const tiles = new Map<string, Tile>();
let trust: TrustLevels = new Map();
let seq = 0;
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);
roster.append(c.el);
function tile(name: string): Tile {
let t = tiles.get(name);
if (!t) {
const view = svgCrow(name, roleOf(name)); // the live, animated crow is the tile's avatar
const box = el("div", `tile role-${roleOf(name)}`);
const text = el("div", "tile-text");
const update = el("p", "update", name === "Sentry" ? "On watch" : "Perched");
text.append(el("p", "tile-name", name), update);
const meta = el("div", "tile-meta");
const time = el("time", "tile-time");
const count = el("span", "tile-count");
meta.append(time, count);
box.append(view.el, text, meta);
roster.append(box);
t = { view, el: box, update, time, count, pending: new Set() };
tiles.set(name, t);
}
return c;
return t;
}
function crow(name: string): CrowView {
return tile(name).view;
}
// Each crow's latest update; the most recently updated tile moves to the top.
type Kind = "" | "waiting" | "good" | "bad";
function update(name: string, text: string, ts?: string, kind: Kind = "") {
const t = tile(name);
t.update.textContent = text;
t.update.className = `update ${kind}`.trim();
t.time.textContent = fmtTime(ts);
t.el.style.order = String(-(++seq));
renderWaitingCount();
}
// What each crow still owes a verdict on. Independent of its latest update,
// so a newer update never hides an open question.
function pending(name: string, key: string, open: boolean) {
const t = tile(name);
if (open) t.pending.add(key); else t.pending.delete(key);
const n = t.pending.size;
t.count.textContent = n ? `${n} waiting` : "";
t.el.classList.toggle("has-pending", n > 0);
renderWaitingCount();
}
// Phone tab badge: how many crows are waiting on you.
const flockTab = document.getElementById("tab-flock");
function renderWaitingCount() {
const n = [...tiles.values()].filter((t) => t.pending.size > 0).length;
if (flockTab) flockTab.dataset.count = n ? String(n) : "";
}
const q = (s: string) => `“${s}”`;
// 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}`;
@@ -163,6 +210,8 @@ const handlers: Record<string, (d: Payload) => void> = {
}
post("you", [ol], d.ts);
crow("Sentry").setPose("perched");
const n = (d.items ?? []).length;
update("Sentry", `Got a list of ${n} item${n === 1 ? "" : "s"}`, d.ts);
},
"item.triaged": (d) => {
@@ -181,6 +230,10 @@ const handlers: Record<string, (d: Payload) => void> = {
if (d.needs_approval) {
crow("Sentry").setPose("waiting");
ask(it.triage, d.item_id, "triage");
update("Sentry", `Waiting on you: plan for ${q(it.text)}`, d.ts, "waiting");
pending("Sentry", `${d.item_id}/triage`, true);
} else {
update("Sentry", `Sorted ${q(it.text)}`, d.ts);
}
},
@@ -188,12 +241,19 @@ const handlers: Record<string, (d: Payload) => void> = {
const it = items.get(d.item_id);
if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
crow(d.crow).setPose("flying");
if (it) {
const doing = d.crow === "Sentry" ? "Sorting" : d.crow === "Elder" ? "Merging reports on" : "Flying";
update(d.crow, `${doing}${doing === "Flying" ? ":" : ""} ${q(it.text)}`, d.ts);
}
flying.add(d.crow);
renderTyping();
},
"crow.landed": (d) => {
crow(d.crow).setPose(d.ok ? "perched" : "grounded");
const it = items.get(d.item_id);
if (it && !d.ok) update(d.crow, `Grounded on ${q(it.text)}`, d.ts, "bad");
else if (it && d.crow !== "Sentry" && d.crow !== "Elder") update(d.crow, `Reported back on ${q(it.text)}`, d.ts);
flying.delete(d.crow);
renderTyping();
},
@@ -208,7 +268,11 @@ const handlers: Record<string, (d: Payload) => void> = {
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 it.crows) crow(n).setPose("waiting");
for (const n of it.crows) {
crow(n).setPose("waiting");
update(n, `Waiting on you: ${q(it.text)}`, d.ts, "waiting");
pending(n, `${d.item_id}/output`, true);
}
ask(it.answer, d.item_id, "output");
}
},
@@ -216,6 +280,7 @@ const handlers: Record<string, (d: Payload) => void> = {
verdict: (d) => {
const it = items.get(d.item_id);
if (!it) return;
for (const n of d.target === "triage" ? ["Sentry"] : it.crows) pending(n, `${d.item_id}/${d.target}`, false);
const m = d.target === "triage" ? it.triage : it.answer;
const good = d.verdict === "approved" || d.verdict === "auto";
if (m) {
@@ -229,9 +294,12 @@ const handlers: Record<string, (d: Payload) => void> = {
react(m, text, kind);
}
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
const word: Record<string, string> = { approved: "Approved", rejected: "Rejected", auto: "Accepted (trusted)", timeout: "Timed out" };
for (const n of d.target === "triage" ? ["Sentry"] : it.crows) {
crow(n).fire(good ? "cheer" : "sulk");
crow(n).setPose("perched");
crow(n).setPose(tile(n).pending.size ? "waiting" : "perched");
const what = d.target === "triage" ? `plan for ${q(it.text)}` : q(it.text);
update(n, `${word[d.verdict] ?? d.verdict}: ${what}`, d.ts, good ? "good" : "bad");
}
},
@@ -246,6 +314,7 @@ const handlers: Record<string, (d: Payload) => void> = {
const c = crow(d.holder);
c.setTrust(d.to);
c.fire(up ? "levelUp" : "levelDown");
update(d.holder, `${up ? "Leveled up to" : "Dropped to"} ${LEVELS[d.to]} on ${what}`, d.ts, up ? "good" : "bad");
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
},
@@ -253,12 +322,13 @@ const handlers: Record<string, (d: Payload) => void> = {
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");
update("Sentry", `Skipped ${q(it?.text ?? d.item_id)}`, d.ts, "bad");
},
"requester.pranked": (d) => prank(d.message, d.cooldown_s),
"list.finished": (d) => {
for (const c of crows.values()) { c.setPose("perched"); c.fire("cheer"); }
for (const t of tiles.values()) { t.view.setPose("perched"); t.view.fire("cheer"); }
flying.clear();
renderTyping();
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`);
@@ -274,6 +344,7 @@ const sendLabel = sendBtn.textContent ?? "Send";
function prank(message: string, cooldownS: number) {
crow("Sentry").fire("prank");
post("Sentry", [el("p", "", message)]);
update("Sentry", "Left a gift on someone's windshield", undefined, "bad");
let left = Math.max(0, Math.round(cooldownS));
clearInterval(cooldownTimer);
const tick = () => {
@@ -321,6 +392,16 @@ form.addEventListener("submit", async (e) => {
}
});
// Phone tabs: the channel or the flock.
const app = document.getElementById("app")!;
for (const [id, view] of [["tab-room", "room"], ["tab-flock", "flock"]] as const) {
document.getElementById(id)!.addEventListener("click", () => {
app.dataset.view = view;
for (const t of document.querySelectorAll<HTMLButtonElement>(".tab")) t.setAttribute("aria-pressed", String(t.id === id));
if (view === "room") feed.scrollTop = feed.scrollHeight;
});
}
// 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.")]);
+48 -28
View File
@@ -54,7 +54,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.btn-no::before { background: var(--paper); }
/* ---------- app frame: sidebar + room ---------- */
.app { display: grid; grid-template-columns: 248px minmax(0, 1fr); height: 100vh; height: 100dvh; }
.app { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: 100vh; height: 100dvh; }
.side {
position: relative; z-index: 1; display: flex; flex-direction: column; gap: 12px;
@@ -73,21 +73,31 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
}
.who input:focus { outline: none; border-bottom-width: 3px; }
/* ---------- roster: live crows ---------- */
/* ---------- roster: one list tile per crow, newest update on top ---------- */
.roster { display: flex; flex-direction: column; gap: 2px; }
.tile {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 10px;
padding: 7px 8px; border-radius: var(--corner);
}
.tile.has-pending { background: rgb(155 47 34 / 0.06); }
.tile-text { min-width: 0; }
.tile-name { margin: 0; font: 400 16px/1.15 var(--display); }
.update {
margin: 1px 0 0; font: italic 500 14px/1.3 var(--body); color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.update.waiting { color: var(--red); }
.update.good { color: var(--ink); }
.tile-meta { align-self: stretch; display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; }
.tile-time { margin-top: 2px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.tile-count:not(:empty) {
padding: 0 7px; border-radius: 999px; background: var(--red); color: var(--paper);
font: 700 12px/1.4 var(--body); white-space: nowrap;
}
.crow { margin: 0; position: relative; }
.crow.role-sentry { order: -1; }
.crow.role-elder { order: 99; }
.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 figcaption { display: none; } /* the tile shows the name */
.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; }
@@ -96,7 +106,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.crow .jaw { transform-origin: 211px 182px; transition: transform 150ms; }
.crow .badge {
width: 16px; height: 16px; fill: var(--card); stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round;
position: absolute; top: -4px; right: -6px; width: 14px; height: 14px; 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; }
@@ -124,6 +134,8 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.crow *, .crow::before, .crow::after { animation: none !important; transition: none !important; }
}
.tabs { display: none; }
/* ---------- 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; }
@@ -188,23 +200,31 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.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 ---------- */
/* ---------- phones: two tabs, the channel or the 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); }
.tabs {
position: relative; z-index: 2; display: flex; align-items: center; gap: 2px; padding: 10px 10px 8px 14px; background: var(--card);
}
.tabs::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -8px; height: 8px; background: var(--stipple); pointer-events: none;
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.7), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.7), transparent);
}
.tabs .brand { margin-right: auto; font-size: 19px; padding: 0; }
.tab { padding: 6px 8px; font: 400 16px/1 var(--display); color: var(--muted); background: none; border: 0; cursor: pointer; }
.tab[aria-pressed="true"] { color: var(--ink); text-decoration: underline 2px; text-underline-offset: 6px; }
.tab:focus-visible { outline: none; text-decoration: underline 3px; text-underline-offset: 6px; }
.tab[data-count]:not([data-count=""])::after {
content: attr(data-count); display: inline-block; margin-left: 5px; padding: 1px 7px; border-radius: 999px;
background: var(--red); color: var(--paper); font: 700 12px/1.3 var(--body); vertical-align: 2px;
}
.side, .room { grid-row: 2; min-width: 0; }
.app[data-view="room"] .side { display: none; }
.app[data-view="flock"] .room { display: none; }
.side { padding: 14px 6px 16px; }
.side::after, .side .brand { display: none; }
.side h2 { margin: 4px 10px 6px; }
.who { padding: 0 10px; }
.room-head { padding: 12px 14px 10px; }
.room-head h1 { font-size: 19px; }
.room-sub { display: none; }