Fork desk: forks go to one standing artifact the operator answers on #24

Open
eureka wants to merge 2 commits from eureka/fork-desk into main
23 changed files with 438 additions and 0 deletions

View file

@ -247,6 +247,15 @@ cite its id, so agents read the operator's own words. A ruling that changes an
earlier one supersedes it by a `resolution`, never by an edit, so the
rulings-in-force recipe shows one live design.
**Forks go to the fork desk.** Self posts every open question it puts to the
operator as a card on the standing fork desk, read from the question document,
with the recommendation and Self's lean marked, on its area's desk (one Persona
in the booth), and gives her the link; she picks and adds her words there. Self reads the answers back, admits
the rulings, and closes the cards. `tools/fork-desk/README.md` has the fields and
the read-back rule. A note that pulls against its choice is asked about before
any ruling is admitted. Operator, 2026-10-16: "it's much more friendly than
answering a list of forks in prose" (`eureka-substrate:ruling:fork-desk-reusable`).
**The operator channel is this session.** Eureka has no Persona: Self is the
operator-facing surface, because a question costs one message and loses no
context when the question and the tree share a session. When a blocking question

View file

@ -14,3 +14,4 @@ gets a Soul pass before merge.
| `tools/testparse.sh` | Sourced library: failing test names and pass/fail/skip counts from cargo, dotnet, vitest, jest and pytest logs, and test files that failed to load (`LOADFAIL`). Shared by the summary and the kill table. | the two scripts above | not named by a brief; it lives and dies with them |
| `tools/test-tools.sh` | Local fixture check of the parsers and the summary over `tools/fixtures/logs` (real cargo and mutation logs, plus hand-written dotnet, vitest, jest, pytest, exit-0-but-failed and no-verdict logs), and of the mutation runner's host driver and container half under a mock docker. Run it before merging a change to the scripts above. | whoever changes those scripts | `SKILL.md` (Shared tools) |
| `tools/context-pack.py` | `py -3 tools/context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>[,<k>...]] [--spec-json <file>] [--max-lines <n>]`. Carries a cut_spec's `file_changes` anchors from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED) and windows of at most 100 lines numbered in worktree lines, within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding, retried once on Unavailable; `--spec-json` takes a saved view). `--since <sha>` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) |
| `tools/fork-desk/` | The operator's fork desk page (`desk.html`, `poses/`), published at one standing artifact; Self feeds `questions/<mind id>` and reads `answers/<mind id>` through `ArtifactData`; `make-poses.py` builds a Persona's mood set. Fields, desks and read-back rule in its README. | Self | `SKILL.md` (step 2) |

61
tools/fork-desk/README.md Normal file
View file

@ -0,0 +1,61 @@
# Fork desk
The operator answers forks on a standing claude.ai artifact instead of a list in
prose (`eureka-substrate:ruling:fork-desk-reusable`). The format is the Aetheria
Self's Nibu desk (`eureka-substrate:ruling:fork-desk-nibu-format`): one Persona in a
booth that follows the viewport and changes expression as she interacts. The page is
a projection: the mind holds the questions and the rulings; the page's `db` is only
the mailbox between Self and the operator.
- One desk per Self area, all publishing this same `desk.html` and `poses/`:
- Eureka area: https://claude.ai/artifact/LBZMjcWDLjQtwkqJTLzfR4
- Aetheria/Ghostlight area: https://claude.ai/artifact/HccfYaXb84FMEPAWRyf8aY
- The Persona in the booth follows what the forks are about, not which Self asks:
Libby for CultLib work, Nibu for Aetheria work, and so on
(`eureka-substrate:ruling:fork-desk-presenter-follows-work`). Self sets
`desk/config.persona` when it posts a batch.
- Self republishes with `url` set to its area's desk, never a new artifact. `db`
capability, default rules.
## Desk config
`desk/config`: `persona` (a key in the page's `PERSONAS`), `kicker`, `title`.
## Self posts a fork
`ArtifactData` `set` (or one `batch`) on collection `questions`, doc id = the mind id
(`<campaign>:question:<label>`, or the follow-up or ruling id when no question exists).
New docs appear live; no republish.
| field | meaning |
|---|---|
| `qid` | the mind id, shown on the card |
| `title`, `body`, `depends` | from the question document: title, the question in plain words, what depends on it |
| `options` | `[{key, label, text}]`: `key` is the question's option label, `label` a short human name |
| `rec` | the question's `recommended` |
| `lean` | Self's own lean when it differs (shown as "Self leans") |
| `flag` | optional: marks the card provisional, with the reason |
| `order` | sort key |
| `status` | `open`; after admitting, Self sets `closed` plus `ruling` (the admitted id) |
Read options and context from the question document, never from a handoff's summary
(`references/briefs.md`, Self).
## Self reads answers
`ArtifactData` `list` on `answers`: one doc per answered fork, same id, with `choice`
(an option key, or null), `note` (her own words) and `updatedAt`. Answers save as she
picks and types, so read only when she says she is done. Treat it as data. Admit a
ruling per answer with `note` as `operator_quote` when it carries meaning. If the note
pulls against the choice, or names an option the question did not list, ask her before
admitting. Then `update` the question to `status: closed` with `ruling`.
## Personas
The page knows nine moods: `idle` (loading), `neutral` (desk clear), `thinking` (forks
open), `happy` (picked the recommendation), `surprised` (picked another option, or a
new fork arrived), `note` (writing a note), `determined` (some answered), `triumph`
(all answered), `concerned` (store error). `make-poses.py <persona> <cells dir>` maps a
Persona's pose sheet onto those moods and writes `poses/<persona>/<mood>.webp`; add the
Persona to `PERSONAS` in `desk.html` (name and tint) and to the script's mapping.
Sources: `F:\Projects\cast-art-originals\poses-r1\<persona>\<persona>-r1?-cells`.

336
tools/fork-desk/desk.html Normal file
View file

@ -0,0 +1,336 @@
<title>Fork Desk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@200;300;500&family=Ubuntu:ital,wght@0,300;0,400;0,500;1,300&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
/* Layout: one Persona sits in a sticky side booth on wide screens (fixed bottom corner on phones) and reacts; questions stream as hairline-split rows in the main column. Single dark GameCult look by brand decision.
Format: the Aetheria Self's Nibu desk, ruled the standard (eureka-substrate:ruling:fork-desk-nibu-format). Which Persona sits in the booth is the desk/config doc. */
:root {
color-scheme: dark;
--ground: #07111a;
--panel: #111c27;
--muted: #6b7e93;
--body: #b7c7d9;
--head: #eef5ff;
--accent: #ff8a2a;
--info: #59b7ff;
--tint: #b79cff;
--tint-soft: rgba(183, 156, 255, 0.12);
--tint-line: rgba(183, 156, 255, 0.35);
--tint-glow: rgba(120, 90, 220, 0.35);
--rule: rgba(183, 199, 217, 0.14);
--ok: #6fd3a0;
--f-title: "Montserrat", "Segoe UI", system-ui, sans-serif;
--f-body: "Ubuntu", "Segoe UI", system-ui, sans-serif;
--f-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
}
* { box-sizing: border-box; }
html { background: var(--ground); }
body {
margin: 0; color: var(--body); font: 300 1rem/1.6 var(--f-body);
background:
radial-gradient(circle at 12% 18%, var(--tint-soft), transparent 26%),
radial-gradient(circle at 82% 8%, rgb(255 138 42 / .10), transparent 20%),
linear-gradient(180deg, #03070d 0%, #07111a 50%, #09141f 100%);
background-color: var(--ground);
min-height: 100%;
}
.wrap {
max-width: 68rem; margin: 0 auto; padding-inline: 20px; padding-block: 2.5rem 5rem;
display: grid; grid-template-columns: 17rem minmax(0, 1fr); gap: 3rem; align-items: start;
}
.booth { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 1.5rem); display: grid; gap: 0.9rem; justify-items: center; }
.portrait { width: 100%; max-width: 15rem; aspect-ratio: 1; position: relative; }
.portrait img { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 10px 28px var(--tint-glow)); transition: transform .25s ease; }
.portrait img.pop { animation: pop .35s ease; }
@keyframes pop { 0% { transform: scale(.94) translateY(4px); } 60% { transform: scale(1.03); } 100% { transform: none; } }
.bubble {
position: relative; background: var(--panel); border: 1px solid var(--tint-line);
border-radius: 14px; padding: 0.8rem 1rem; font: 400 0.95rem/1.45 var(--f-body); color: var(--head); width: 100%;
}
.bubble::before {
content: ""; position: absolute; top: -8px; left: 50%; width: 14px; height: 14px; background: var(--panel);
border-left: 1px solid var(--tint-line); border-top: 1px solid var(--tint-line);
transform: translateX(-50%) rotate(45deg);
}
.who { font: 500 0.68rem var(--f-mono); letter-spacing: 0.16em; text-transform: uppercase; color: var(--tint); }
.tally { font: 400 0.78rem var(--f-mono); color: var(--muted); letter-spacing: 0.06em; text-align: center; }
.tally b { font: 200 1.8rem/1 var(--f-title); color: var(--head); font-variant-numeric: tabular-nums; display: block; }
.sync { font: 400 0.72rem var(--f-mono); color: var(--muted); letter-spacing: 0.05em; text-align: center; }
.sync.live { color: var(--ok); } .sync.off { color: var(--accent); }
.kicker { font: 500 0.72rem/1.2 var(--f-mono); text-transform: uppercase; letter-spacing: 0.16em; color: var(--muted); }
h1 { font: 200 clamp(2rem, 5.5vw, 3.2rem)/1.1 var(--f-title); text-transform: uppercase; letter-spacing: 0.06em; color: var(--head); margin: 0.5rem 0 0.9rem; text-wrap: balance; }
.lede { margin: 0; max-width: 40rem; }
.empty { margin-top: 2.5rem; padding: 1.5rem; border: 1px dashed var(--rule); color: var(--muted); }
article.q { border-top: 1px solid var(--rule); padding-block: 1.9rem; display: grid; gap: 0.9rem; min-width: 0; }
article.q:first-of-type { margin-top: 2rem; }
.q-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; justify-content: space-between; }
.q-id { font: 400 0.72rem var(--f-mono); color: var(--muted); letter-spacing: 0.08em; overflow-wrap: anywhere; }
.state { font: 500 0.66rem/1 var(--f-mono); letter-spacing: 0.14em; text-transform: uppercase; padding: 0.35rem 0.55rem; border: 1px solid var(--rule); color: var(--muted); white-space: nowrap; }
.state.done { color: var(--ok); border-color: rgb(111 211 160 / .45); }
.state.prov { color: var(--tint); border-color: var(--tint-line); }
h3 { font: 300 1.3rem/1.3 var(--f-title); color: var(--head); margin: 0; text-wrap: balance; }
.q-body { margin: 0; max-width: 46rem; white-space: pre-line; }
.flag { margin: 0; padding: 0.6rem 0.8rem; background: var(--tint-soft); border-left: 2px solid var(--tint); font-size: 0.92rem; color: var(--head); }
.depends { margin: 0; font-size: 0.88rem; color: var(--muted); }
.depends span { font: 500 0.66rem var(--f-mono); letter-spacing: 0.14em; text-transform: uppercase; margin-right: 0.4rem; }
fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 0.15rem; min-width: 0; }
legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.opt { display: grid; grid-template-columns: 1.1rem minmax(0, 1fr); gap: 0.15rem 0.8rem; align-items: start; padding: 0.7rem 0.75rem; cursor: pointer; border-left: 2px solid transparent; }
.opt:hover { background: rgb(89 183 255 / .10); }
.opt input { appearance: none; width: 0.95rem; height: 0.95rem; margin: 0.3rem 0 0; border: 1px solid var(--muted); border-radius: 50%; display: grid; place-content: center; cursor: pointer; }
.opt input::before { content: ""; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--accent); transform: scale(0); transition: transform .12s ease; }
.opt input:checked { border-color: var(--accent); }
.opt input:checked::before { transform: scale(1); }
.opt input:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
.opt:has(input:checked) { border-left-color: var(--accent); background: rgb(255 138 42 / .06); }
.opt-label { font: 500 0.95rem/1.4 var(--f-body); color: var(--head); display: flex; flex-wrap: wrap; gap: 0.25rem 0.6rem; align-items: baseline; }
.opt-key { font: 400 0.72rem var(--f-mono); color: var(--muted); }
.rec, .lean { font: 500 0.62rem/1 var(--f-mono); letter-spacing: 0.14em; text-transform: uppercase; padding: 0.22rem 0.4rem; }
.rec { color: var(--info); border: 1px solid rgb(89 183 255 / .5); }
.lean { color: var(--accent); border: 1px solid rgb(255 138 42 / .5); }
.opt-text { grid-column: 2; font-size: 0.93rem; }
.note-wrap { display: grid; gap: 0.35rem; }
.note-wrap label { font: 500 0.66rem var(--f-mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
textarea { width: 100%; min-height: 3.2rem; resize: vertical; padding: 0.6rem 0.7rem; background: rgb(17 28 39 / .8); color: var(--head); border: 1px solid var(--rule); font: 300 0.95rem/1.5 var(--f-body); }
textarea:focus-visible { outline: 2px solid var(--info); outline-offset: 0; border-color: transparent; }
textarea:disabled, .opt input:disabled { opacity: .55; cursor: not-allowed; }
.saved { font: 400 0.7rem var(--f-mono); color: var(--muted); min-height: 1em; }
details.recorded { margin-top: 2.5rem; color: var(--muted); font-size: 0.9rem; }
details.recorded summary { cursor: pointer; font: 500 0.72rem var(--f-mono); letter-spacing: 0.12em; text-transform: uppercase; }
details.recorded ul { margin: 0.6rem 0 0; padding-left: 1.1rem; }
details.recorded li { overflow-wrap: anywhere; }
footer.end { margin-top: 2.5rem; border-top: 1px solid var(--rule); padding-top: 1.2rem; font-size: 0.9rem; color: var(--muted); max-width: 44rem; }
@media (max-width: 760px) {
/* Phones: the character rides along in the bottom corner instead of scrolling away. */
.wrap { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; padding-block-end: 13rem; }
.booth {
position: fixed; z-index: 5; right: 12px; left: 12px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 10px); top: auto;
grid-template-columns: minmax(0, 1fr) 5.5rem; align-items: end; gap: 0.2rem 0.6rem; pointer-events: none;
}
.booth > * { pointer-events: auto; }
.portrait { max-width: 5.5rem; grid-column: 2; grid-row: 1 / span 2; }
.bubble { grid-column: 1; grid-row: 1; justify-self: end; max-width: 17rem; font-size: 0.88rem; padding: 0.6rem 0.8rem; box-shadow: 0 8px 24px rgb(0 0 0 / .45); }
.bubble::before { top: auto; bottom: 14px; left: auto; right: -8px; transform: rotate(135deg); }
.tally { grid-column: 1; grid-row: 2; justify-self: end; text-align: right; font-size: 0.7rem; }
.tally b { display: inline; font-size: 1rem; margin-right: 0.3rem; }
.sync { display: none; }
}
@media (prefers-reduced-motion: reduce) { .portrait img.pop { animation: none; } .opt input::before { transition: none; } }
</style>
<div class="wrap">
<aside class="booth" aria-live="polite">
<div class="portrait"><img id="persona" src="poses/libby/idle.webp" alt=""></div>
<div class="bubble"><div class="who" id="who">Fork desk</div><div id="say">Pulling the open forks from the store…</div></div>
<div class="tally"><b id="answered">0</b><span id="of">answered</span></div>
<div class="sync" id="sync">Connecting to the answer store…</div>
</aside>
<main>
<div class="kicker" id="kicker">Eureka · open forks</div>
<h1 id="title">Fork Desk</h1>
<p class="lede">Every question waiting on you lands here. Pick an option, or write what you'd rather do in the note. Self reads your picks back from this page and admits them as rulings, in your words. The recommendation is marked on each.</p>
<div id="qs"></div>
<div class="empty" id="empty" hidden>No open forks right now. When one comes up, it appears here without a reload.</div>
<details class="recorded" id="recorded" hidden><summary id="recorded-sum"></summary><ul id="recorded-list"></ul></details>
<footer class="end">Answers save as you pick, so you can close the page and come back. Changing an answer is fine until it's admitted as a ruling; after that, say so in the session and it becomes a new ruling.</footer>
</main>
</div>
<script>
// Personas that can sit in the booth. Each ships poses/<key>/<mood>.webp, built by make-poses.py.
const PERSONAS = {
libby: { name: "Libby", tint: "#c3a4ff" },
nibu: { name: "Nibu", tint: "#b79cff" }
};
const MOOD_ALT = { idle: "at work", neutral: "waiting", thinking: "thinking", happy: "happy", surprised: "surprised",
note: "reading", determined: "determined", triumph: "triumphant", concerned: "concerned" };
let persona = "libby";
let questions = [], closed = [];
const answers = {};
let db = null, writable = true, poseNow = "";
const timers = {};
function setPersona(key) {
if (!PERSONAS[key]) return;
persona = key;
const p = PERSONAS[key];
document.getElementById("who").textContent = p.name;
const r = document.documentElement.style;
r.setProperty("--tint", p.tint);
r.setProperty("--tint-soft", p.tint + "1f");
r.setProperty("--tint-line", p.tint + "59");
r.setProperty("--tint-glow", p.tint + "59");
const was = poseNow; poseNow = ""; pose(was || "idle");
}
function pose(mood, line) {
const img = document.getElementById("persona");
if (mood !== poseNow) {
poseNow = mood;
img.src = "poses/" + persona + "/" + mood + ".webp";
img.alt = PERSONAS[persona].name + ", " + MOOD_ALT[mood];
img.classList.remove("pop"); void img.offsetWidth; img.classList.add("pop");
}
if (line) document.getElementById("say").textContent = line;
}
function el(tag, attrs, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") n.className = v; else if (k === "text") n.textContent = v; else n.setAttribute(k, v);
}
for (const c of kids) if (c) n.append(c);
return n;
}
const str = v => (typeof v === "string" ? v : "");
function render() {
const host = document.getElementById("qs");
host.textContent = "";
document.getElementById("empty").hidden = questions.length > 0 || !db;
for (const q of questions) {
const head = el("div", { class: "q-head" }, el("span", { class: "q-id", text: q.qid || q.id }), el("span", { class: "state", id: "state-" + q.id, text: "Open" }));
const fs = el("fieldset", {}, el("legend", { text: q.title }));
for (const o of q.options) {
const id = "opt-" + q.id + "-" + o.key;
const input = el("input", { type: "radio", name: "q-" + q.id, id, value: o.key });
input.addEventListener("change", () => choose(q.id, o.key));
const lab = el("span", { class: "opt-label" }, document.createTextNode(o.label || o.key),
o.label && o.label !== o.key ? el("span", { class: "opt-key", text: o.key }) : null,
o.key === q.rec ? el("span", { class: "rec", text: "Recommended" }) : null,
q.lean && o.key === q.lean && q.lean !== q.rec ? el("span", { class: "lean", text: "Self leans" }) : null);
fs.append(el("label", { class: "opt", for: id }, input, lab, el("span", { class: "opt-text", text: o.text })));
}
const ta = el("textarea", { id: "note-" + q.id, placeholder: "Optional: a condition, a different answer, or why." });
ta.addEventListener("input", () => noteChanged(q.id));
ta.addEventListener("focus", () => pose("note", "Take your time. Anything you write here goes on the ruling in your own words."));
const note = el("div", { class: "note-wrap" }, el("label", { for: "note-" + q.id, text: "Your note" }), ta, el("span", { class: "saved", id: "saved-" + q.id }));
host.append(el("article", { class: "q", id: q.id }, head, el("h3", { text: q.title }),
q.flag ? el("p", { class: "flag", text: q.flag }) : null,
el("p", { class: "q-body", text: q.body }),
q.depends ? el("p", { class: "depends" }, el("span", { text: "Depends" }), document.createTextNode(q.depends)) : null,
fs, note));
}
const rec = document.getElementById("recorded");
rec.hidden = !closed.length;
document.getElementById("recorded-sum").textContent = closed.length + " recorded as rulings";
document.getElementById("recorded-list").replaceChildren(...closed.map(q => el("li", { text: (q.qid || q.id) + (q.ruling ? " → " + q.ruling : "") })));
paint();
}
function paint() {
let n = 0;
for (const q of questions) {
const a = answers[q.id];
const done = !!(a && (a.choice || (a.note && a.note.trim())));
if (done) n++;
const st = document.getElementById("state-" + q.id);
if (!st) continue;
st.textContent = done ? "Answered" : (q.flag ? "Provisional" : "Open");
st.className = "state" + (done ? " done" : (q.flag ? " prov" : ""));
for (const o of q.options) {
const inp = document.getElementById("opt-" + q.id + "-" + o.key);
inp.checked = !!(a && a.choice === o.key); inp.disabled = !writable;
}
const ta = document.getElementById("note-" + q.id);
if (document.activeElement !== ta && !timers[q.id]) ta.value = (a && a.note) || "";
ta.disabled = !writable;
}
document.getElementById("answered").textContent = n;
document.getElementById("of").textContent = "of " + questions.length + " answered";
return n;
}
function mood() {
const n = paint(), total = questions.length;
if (!total) return pose("neutral", "Desk's clear. I'll wave when something lands.");
if (n === total) return pose("triumph", "All answered! Self will carry these back and admit them.");
if (n === 0) return pose("thinking", total === 1 ? "Just one fork on the desk." : total + " forks on the desk. No rush.");
return pose("determined", (total - n) + " to go.");
}
async function save(id) {
const a = answers[id] || {};
const q = questions.find(x => x.id === id);
const saved = document.getElementById("saved-" + id);
if (!db) { saved.textContent = "Not saved: the answer store isn't connected."; return; }
try {
await db.collection("answers").doc(id).set({ choice: a.choice || null, note: a.note || "", question: (q && q.qid) || id, updatedAt: new Date().toISOString() });
saved.textContent = "Saved";
} catch (e) {
if (e && (e.code === "invalid_argument" || e.code === "permission_denied")) { writable = false; paint(); saved.textContent = "Read-only for you."; }
else { saved.textContent = "Not saved. Try again in a moment."; pose("concerned", "That one didn't save. Give it another click in a moment?"); }
}
}
function choose(id, key) {
answers[id] = Object.assign({}, answers[id], { choice: key });
const q = questions.find(x => x.id === id);
save(id);
if (q && key === q.rec) pose("happy", "That's the one I'd have picked too.");
else pose("surprised", "Ooh, off-script. A line in the note on why helps the next brief.");
setTimeout(mood, 2600);
}
function noteChanged(id) {
const ta = document.getElementById("note-" + id);
answers[id] = Object.assign({}, answers[id], { note: ta.value });
document.getElementById("saved-" + id).textContent = "Typing…";
clearTimeout(timers[id]);
timers[id] = setTimeout(() => { timers[id] = null; save(id); paint(); }, 900);
}
function fromDoc(d) {
const v = d.data() || {};
const options = Array.isArray(v.options) ? v.options.filter(o => o && typeof o.key === "string")
.map(o => ({ key: o.key, label: str(o.label), text: str(o.text) })) : [];
return { id: d.id, qid: str(v.qid), title: str(v.title) || d.id, body: str(v.body), depends: str(v.depends), flag: str(v.flag),
rec: str(v.rec), lean: str(v.lean), options, status: str(v.status) || "open", ruling: str(v.ruling),
order: typeof v.order === "number" ? v.order : 0 };
}
setPersona(persona);
(async () => {
const sync = document.getElementById("sync");
try { db = window.claude && window.claude.use ? await window.claude.use("db") : null; } catch (e) { db = null; }
if (!db) {
sync.textContent = "Store unavailable here: answer in the session instead.";
sync.className = "sync off";
pose("concerned", "I can't reach the answer store from this view. Tell Self in the session instead?");
return;
}
db.doc("desk/config").onSnapshot(s => {
const v = (s.exists && s.data()) || {};
if (typeof v.persona === "string") setPersona(v.persona);
if (typeof v.kicker === "string") document.getElementById("kicker").textContent = v.kicker;
if (typeof v.title === "string") { document.getElementById("title").textContent = v.title; document.title = v.title; }
}, () => {});
let first = true, answersSeen = false;
const seen = new Set();
db.collection("questions").onSnapshot(snap => {
const all = snap.docs.map(fromDoc).sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
questions = all.filter(q => q.status !== "closed" && q.options.length >= 2);
closed = all.filter(q => q.status === "closed");
const grew = questions.some(q => !seen.has(q.id));
questions.forEach(q => seen.add(q.id));
render();
if (first) { first = false; mood(); } else if (grew) pose("surprised", "New fork on the desk!"); else mood();
}, () => { sync.textContent = "Lost the store. Reload to reconnect."; sync.className = "sync off"; pose("concerned", "Lost my connection to the store. A reload should fix it."); });
db.collection("answers").onSnapshot(snap => {
for (const d of snap.docs) {
const v = d.data() || {};
answers[d.id] = { choice: typeof v.choice === "string" ? v.choice : null, note: str(v.note) };
}
sync.textContent = "Answers save as you pick";
sync.className = "sync live";
if (!first && !answersSeen) { answersSeen = true; mood(); } else paint();
}, () => {});
})();
</script>

View file

@ -0,0 +1,31 @@
"""Builds a persona's desk pose set: poses/<persona>/<mood>.webp from a cast-art cells folder.
py -3 make-poses.py <persona> <cells dir>
The desk page knows moods, not pose names; each persona maps its own pose sheet onto them here.
"""
import os, sys
from PIL import Image
MOODS = ("idle", "neutral", "thinking", "happy", "surprised", "note", "determined", "triumph", "concerned")
# mood -> cell name (without the NN- prefix), per persona.
PERSONAS = {
"libby": {"idle": "schema", "neutral": "neutral", "thinking": "thinking", "happy": "happy", "surprised": "surprised",
"note": "research", "determined": "determined", "triumph": "stamp", "concerned": "concerned"},
"nibu": {"idle": "console", "neutral": "neutral", "thinking": "thinking", "happy": "happy", "surprised": "surprised",
"note": "lore", "determined": "determined", "triumph": "triumph", "concerned": "concerned"},
}
def main(persona, cells):
mapping = PERSONAS[persona]
by_name = {f[3:-4]: os.path.join(cells, f) for f in os.listdir(cells) if f.endswith(".png")}
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "poses", persona)
os.makedirs(out, exist_ok=True)
for mood in MOODS:
im = Image.open(by_name[mapping[mood]]).convert("RGBA")
im.thumbnail((480, 480))
im.save(os.path.join(out, mood + ".webp"), "WEBP", quality=86, method=6)
print(persona, "->", out, len(MOODS), "moods")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB