Fork desk: forks go to one standing artifact the operator answers on #24
Fork desk: forks go to one standing artifact the operator answers on
desk.html renders open forks from its db (forks/<mind id>) with the asking Persona's pose beside each card; the operator's picks land in answers/<mind id> for Self to admit as rulings. SKILL.md step 2 names the flow; the README holds the fields and the read-back rule. Operator ruling eureka-substrate:ruling:fork-desk-reusable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9
SKILL.md
|
|
@ -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 asking Persona's pose and one line of Self's lean, 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
|
||||
|
|
|
|||
|
|
@ -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 `forks/<mind id>` and reads `answers/<mind id>` through `ArtifactData`. Field table and read-back rule in its README. | Self | `SKILL.md` (step 2) |
|
||||
|
|
|
|||
45
tools/fork-desk/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Fork desk
|
||||
|
||||
The operator answers forks on one standing claude.ai artifact instead of a list in
|
||||
prose (operator ruling `eureka-substrate:ruling:fork-desk-reusable`). 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.
|
||||
|
||||
- Desk: https://claude.ai/artifact/LBZMjcWDLjQtwkqJTLzfR4 (owner: the operator; Self
|
||||
republishes `desk.html` and `poses/` with `url` set to it, never a new artifact).
|
||||
- `db` capability, default rules (signed-in editors write).
|
||||
|
||||
## Self posts a fork
|
||||
|
||||
`ArtifactData` `set` (or one `batch`) on collection `forks`, doc id = the mind id
|
||||
(`<campaign>:question:<label>`, or the follow-up or ruling id when no question exists):
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `id`, `label` | the mind id and its short label |
|
||||
| `title`, `context`, `blocks` | from the question document: title, the question in plain words, what depends on it |
|
||||
| `options` | `[{label, text}]`, the question's own options, at least two |
|
||||
| `recommended` | the question's `recommended` |
|
||||
| `lean` | Self's own lean when it differs (shown as "Self leans") |
|
||||
| `persona`, `pose`, `say` | who asks: a pose name under `poses/<persona>/`, and one line in their voice |
|
||||
| `order` | sort key |
|
||||
| `status` | `open`; Self sets `closed` plus `ruling` (the admitted id) after admitting |
|
||||
|
||||
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`,
|
||||
`note` (her own words) and `saved_at`. 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, ask her before admitting. Then `update` the fork to `status: closed` with
|
||||
`ruling`.
|
||||
|
||||
## Poses
|
||||
|
||||
`poses/libby/` is Libby's r1a pose sheet (cast-art-originals `poses-r1/libby/libby-r1a-cells`),
|
||||
resized to 360 px WebP. Another Persona's set goes under `poses/<persona>/` with the
|
||||
same pose names (neutral, happy, amused, thinking, concerned, annoyed, surprised,
|
||||
determined, stamp, research, shush, schema); the page uses `happy`, `neutral`,
|
||||
`stamp` and `shush` for its own states.
|
||||
249
tools/fork-desk/desk.html
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<title>Fork Desk</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@200;300;400;600&family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
<style>
|
||||
/* Layout: one column of fork cards on the GameCult night ground; the asking Persona sits beside each card and reacts to its state.
|
||||
Forks and answers live in the page's db: Self writes forks/<mind id>, the operator writes answers/<mind id>. */
|
||||
:root {
|
||||
--ground: #07111a;
|
||||
--panel: #0e1a25;
|
||||
--rule: #16212c;
|
||||
--muted: #63758a;
|
||||
--soft: #b7c7d9;
|
||||
--ink: #eef5ff;
|
||||
--ember: #ff8a2a;
|
||||
--sky: #59b7ff;
|
||||
--sky-wash: rgba(89, 183, 255, 0.14);
|
||||
--ember-wash: rgba(255, 138, 42, 0.14);
|
||||
--alarm: #ff6b6b;
|
||||
--f-display: "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;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: radial-gradient(1200px 600px at 80% -10%, rgba(89,183,255,.10), transparent 60%), var(--ground);
|
||||
color: var(--ink); font-family: var(--f-body); font-size: 15.5px; line-height: 1.55;
|
||||
padding-inline: 16px; padding-block: 32px 64px; min-height: 100%;
|
||||
}
|
||||
.wrap { max-width: 920px; margin: 0 auto; display: grid; gap: 28px; }
|
||||
header.hero { display: grid; grid-template-columns: 150px 1fr; gap: 20px; align-items: center; }
|
||||
header.hero img { width: 150px; height: auto; filter: drop-shadow(0 6px 18px rgba(0,0,0,.5)); }
|
||||
.eyebrow { font-family: var(--f-mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--sky); }
|
||||
h1 { font-family: var(--f-display); font-weight: 200; font-size: clamp(30px, 5vw, 44px); line-height: 1.1; margin: 4px 0 8px; text-wrap: balance; }
|
||||
h1 b { font-weight: 600; color: var(--ember); }
|
||||
.lede { color: var(--soft); max-width: 62ch; margin: 0; }
|
||||
.tally { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.pip { font-family: var(--f-mono); font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
|
||||
.pip.done { color: var(--ground); background: var(--ember); border-color: var(--ember); }
|
||||
.list { display: grid; gap: 28px; }
|
||||
|
||||
.fork { display: grid; grid-template-columns: 1fr 132px; gap: 18px; align-items: start; }
|
||||
.card { background: var(--panel); border: 1px solid var(--rule); border-radius: 14px; padding: 22px 22px 18px; min-width: 0; display: grid; gap: 14px; }
|
||||
.fork.answered .card { border-color: rgba(255,138,42,.45); }
|
||||
.meta { display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; justify-content: space-between; }
|
||||
.id { font-family: var(--f-mono); font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.blocks { font-size: 12.5px; color: var(--sky); font-family: var(--f-mono); }
|
||||
h2 { font-family: var(--f-display); font-weight: 400; font-size: 21px; line-height: 1.25; margin: 0; text-wrap: balance; }
|
||||
.context { color: var(--soft); margin: 0; max-width: 68ch; }
|
||||
.opts { display: grid; gap: 8px; border: 0; padding: 0; margin: 0; }
|
||||
.opt { display: grid; grid-template-columns: 20px 1fr; gap: 10px; padding: 12px 14px; border: 1px solid var(--rule); border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
.opt:hover { border-color: var(--muted); }
|
||||
.opt input { margin: 3px 0 0; accent-color: var(--ember); width: 16px; height: 16px; }
|
||||
.opt:has(input:checked) { border-color: var(--ember); background: var(--ember-wash); }
|
||||
.opt:has(input:focus-visible) { outline: 2px solid var(--sky); outline-offset: 2px; }
|
||||
.opt-head { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.label { font-family: var(--f-mono); font-size: 13.5px; font-weight: 500; color: var(--ink); }
|
||||
.tag { font-family: var(--f-mono); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; padding: 1px 7px; border-radius: 999px; }
|
||||
.tag.rec { background: var(--sky-wash); color: var(--sky); }
|
||||
.tag.lean { background: var(--ember-wash); color: var(--ember); }
|
||||
.opt p { margin: 4px 0 0; color: var(--soft); font-size: 14.5px; }
|
||||
textarea { width: 100%; min-height: 64px; resize: vertical; background: var(--ground); color: var(--ink); border: 1px solid var(--rule); border-radius: 10px; padding: 10px 12px; font: inherit; font-size: 14.5px; }
|
||||
textarea:focus-visible { outline: 2px solid var(--sky); outline-offset: 1px; }
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
|
||||
button { font-family: var(--f-display); font-weight: 600; font-size: 14px; letter-spacing: .02em; background: var(--ember); color: var(--ground); border: 0; border-radius: 999px; padding: 9px 18px; cursor: pointer; }
|
||||
button:disabled { background: var(--rule); color: var(--muted); cursor: default; }
|
||||
button:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
|
||||
.status { font-family: var(--f-mono); font-size: 12.5px; color: var(--muted); }
|
||||
.status.ok { color: var(--ember); }
|
||||
.status.err { color: var(--alarm); }
|
||||
|
||||
.persona { display: grid; gap: 8px; justify-items: center; position: sticky; top: calc(env(safe-area-inset-top, 0px) + 16px); }
|
||||
.persona img { width: 132px; height: auto; transition: transform .25s; filter: drop-shadow(0 6px 14px rgba(0,0,0,.45)); }
|
||||
.fork.answered .persona img { transform: rotate(-3deg); }
|
||||
.bubble { background: var(--ink); color: var(--ground); border-radius: 12px; padding: 8px 10px; font-size: 12.5px; line-height: 1.35; position: relative; text-align: center; }
|
||||
.bubble::before { content: ""; position: absolute; top: -6px; left: 50%; margin-left: -6px; border: 6px solid transparent; border-top: 0; border-bottom-color: var(--ink); }
|
||||
|
||||
.empty, .note { display: grid; grid-template-columns: 96px 1fr; gap: 16px; align-items: center; color: var(--soft); }
|
||||
.empty img, .note img { width: 96px; height: auto; }
|
||||
.empty h2 { margin-bottom: 4px; }
|
||||
.banner { background: var(--ember-wash); border: 1px solid rgba(255,138,42,.4); border-radius: 10px; padding: 10px 14px; color: var(--soft); font-size: 14px; }
|
||||
details.recorded { color: var(--muted); font-size: 14px; }
|
||||
details.recorded summary { cursor: pointer; font-family: var(--f-mono); font-size: 12.5px; }
|
||||
details.recorded ul { margin: 8px 0 0; padding-left: 18px; }
|
||||
details.recorded li { overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
header.hero { grid-template-columns: 96px 1fr; }
|
||||
header.hero img { width: 96px; }
|
||||
.fork { grid-template-columns: 1fr; }
|
||||
.persona { position: static; grid-template-columns: 84px 1fr; justify-items: start; align-items: center; order: -1; }
|
||||
.persona img { width: 84px; }
|
||||
.bubble { text-align: left; }
|
||||
.bubble::before { top: 50%; left: -6px; margin: -6px 0 0; border: 6px solid transparent; border-left: 0; border-right-color: var(--ink); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="hero">
|
||||
<img id="hero-img" src="poses/libby/neutral.webp" alt="">
|
||||
<div>
|
||||
<div class="eyebrow" id="eyebrow">Eureka · fork desk</div>
|
||||
<h1 id="headline">Checking the desk</h1>
|
||||
<p class="lede">Each card is one decision from the mind, with its options as the question states them. Pick one, add your own words if you want them on the ruling, and save. Self reads the answers here and admits them as rulings.</p>
|
||||
<div class="tally" id="tally"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<main id="forks" class="list"></main>
|
||||
<details class="recorded" id="recorded" hidden><summary id="recorded-sum"></summary><ul id="recorded-list"></ul></details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Default persona and pose set shipped with the page. A fork may name another persona whose poses are published at poses/<persona>/.
|
||||
const DEFAULT_PERSONA = "libby";
|
||||
const pose = (persona, name) => "poses/" + (persona || DEFAULT_PERSONA) + "/" + name + ".webp";
|
||||
|
||||
let forks = []; // open forks, from db forks/*
|
||||
let closed = []; // forks Self has admitted rulings for
|
||||
const answers = {}; // answers/<id>, from db
|
||||
let db = null;
|
||||
|
||||
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 k of kids) if (k) n.append(k);
|
||||
return n;
|
||||
}
|
||||
const str = v => (typeof v === "string" ? v : "");
|
||||
|
||||
function renderHeader() {
|
||||
const open = forks.length, done = forks.filter(f => answers[f.key]).length;
|
||||
const persona = (forks[0] && forks[0].persona) || DEFAULT_PERSONA;
|
||||
const h = document.getElementById("headline");
|
||||
h.replaceChildren();
|
||||
if (!db) { h.textContent = "The desk is closed in this view"; }
|
||||
else if (!open) { h.textContent = "Nothing waiting on you"; }
|
||||
else if (done === open) { h.append("All ", el("b", { text: String(open) }), open === 1 ? " fork answered" : " forks answered"); }
|
||||
else { const left = open - done; h.append(left === 1 ? "One fork is " : left + " forks are ", el("b", { text: "waiting on you" })); }
|
||||
document.getElementById("hero-img").src = pose(persona, !open ? "happy" : done === open ? "stamp" : done ? "neutral" : "happy");
|
||||
document.getElementById("tally").replaceChildren(...forks.map(f => el("span", { class: "pip" + (answers[f.key] ? " done" : ""), text: f.label })));
|
||||
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(f => el("li", { text: f.label + (f.ruling ? " → " + f.ruling : "") })));
|
||||
}
|
||||
|
||||
function card(f) {
|
||||
const name = "opt-" + f.key;
|
||||
const fs = el("fieldset", { class: "opts" });
|
||||
fs.append(el("legend", { class: "id", text: "Options" }));
|
||||
for (const o of f.options) {
|
||||
const input = el("input", { type: "radio", name, id: name + "-" + o.label, value: o.label });
|
||||
const head = el("span", { class: "opt-head" }, el("span", { class: "label", text: o.label }));
|
||||
if (o.label === f.recommended) head.append(el("span", { class: "tag rec", text: "recommended" }));
|
||||
if (f.lean && o.label === f.lean && f.lean !== f.recommended) head.append(el("span", { class: "tag lean", text: "Self leans" }));
|
||||
fs.append(el("label", { class: "opt", for: input.id }, input, el("span", {}, head, el("p", { text: o.text }))));
|
||||
}
|
||||
const note = el("textarea", { id: "note-" + f.key, placeholder: "Your words, if you want them on the ruling (optional)" });
|
||||
const btn = el("button", { type: "button", text: "Save answer" });
|
||||
const status = el("span", { class: "status", id: "status-" + f.key, text: "Not answered yet" });
|
||||
const body = el("section", { class: "card" },
|
||||
el("div", { class: "meta" }, el("span", { class: "id", text: f.id }), f.blocks ? el("span", { class: "blocks", text: f.blocks }) : null),
|
||||
el("h2", { text: f.title }),
|
||||
f.context ? el("p", { class: "context", text: f.context }) : null,
|
||||
fs, note, el("div", { class: "row" }, status, btn));
|
||||
const side = el("aside", { class: "persona" },
|
||||
el("img", { src: pose(f.persona, f.pose || "thinking"), alt: "" }),
|
||||
f.say ? el("div", { class: "bubble", text: f.say }) : null);
|
||||
const wrap = el("article", { class: "fork", id: "fork-" + f.key }, body, side);
|
||||
wrap.addEventListener("input", () => { wrap.dataset.dirty = "1"; });
|
||||
btn.addEventListener("click", () => save(f, btn, status));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function paint(f) {
|
||||
const a = answers[f.key];
|
||||
const wrap = document.getElementById("fork-" + f.key);
|
||||
if (!a || !wrap) return;
|
||||
if (!wrap.dataset.dirty) {
|
||||
const r = document.getElementById("opt-" + f.key + "-" + a.choice);
|
||||
if (r) r.checked = true;
|
||||
document.getElementById("note-" + f.key).value = str(a.note);
|
||||
}
|
||||
wrap.classList.add("answered");
|
||||
wrap.querySelector(".persona img").src = pose(f.persona, "stamp");
|
||||
const status = document.getElementById("status-" + f.key);
|
||||
status.className = "status ok"; status.textContent = "Saved: " + a.choice;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const main = document.getElementById("forks");
|
||||
const have = new Set([...main.children].map(c => c.id));
|
||||
const want = new Set(forks.map(f => "fork-" + f.key));
|
||||
for (const c of [...main.children]) if (!want.has(c.id)) c.remove();
|
||||
for (const f of forks) if (!have.has("fork-" + f.key)) main.append(card(f));
|
||||
if (!forks.length && db) {
|
||||
main.replaceChildren(el("div", { class: "empty" },
|
||||
el("img", { src: pose(DEFAULT_PERSONA, "shush"), alt: "" }),
|
||||
el("div", {}, el("h2", { text: "The desk is clear" }), el("span", { text: "When Self has a fork for you, it lands here as a card." }))));
|
||||
} else {
|
||||
const e = main.querySelector(".empty"); if (e) e.remove();
|
||||
}
|
||||
forks.forEach(paint);
|
||||
renderHeader();
|
||||
}
|
||||
|
||||
async function save(f, btn, status) {
|
||||
const picked = document.querySelector('input[name="opt-' + f.key + '"]:checked');
|
||||
if (!picked) { status.className = "status err"; status.textContent = "Pick an option first"; return; }
|
||||
btn.disabled = true; status.className = "status"; status.textContent = "Saving";
|
||||
const body = { choice: picked.value, note: document.getElementById("note-" + f.key).value.trim(), question: f.id, saved_at: new Date().toISOString() };
|
||||
try {
|
||||
await db.doc("answers/" + f.key).set(body);
|
||||
delete document.getElementById("fork-" + f.key).dataset.dirty;
|
||||
answers[f.key] = body; paint(f); renderHeader();
|
||||
} catch (e) {
|
||||
status.className = "status err";
|
||||
status.textContent = e && e.code === "permission_denied" ? "You can't save on this page; tell Self in chat" : "Couldn't save. Try again in a moment";
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
function fromDoc(d) {
|
||||
const v = d.data() || {};
|
||||
const options = Array.isArray(v.options) ? v.options.filter(o => o && typeof o.label === "string").map(o => ({ label: o.label, text: str(o.text) })) : [];
|
||||
return { key: d.id, id: str(v.id) || d.id, label: str(v.label) || d.id, title: str(v.title) || d.id, blocks: str(v.blocks), context: str(v.context),
|
||||
options, recommended: str(v.recommended), lean: str(v.lean), persona: str(v.persona), pose: str(v.pose), say: str(v.say),
|
||||
status: str(v.status) || "open", ruling: str(v.ruling), order: typeof v.order === "number" ? v.order : 0 };
|
||||
}
|
||||
|
||||
render();
|
||||
(async () => {
|
||||
try { db = window.claude ? await window.claude.use("db") : null; } catch { db = null; }
|
||||
const banner = document.getElementById("banner");
|
||||
if (!db) { banner.hidden = false; banner.textContent = "This view can't reach the desk's store (you may be signed out). Tell Self your picks in chat."; render(); return; }
|
||||
db.collection("forks").onSnapshot(snap => {
|
||||
const all = snap.docs.map(fromDoc).sort((a, b) => a.order - b.order || a.key.localeCompare(b.key));
|
||||
forks = all.filter(f => f.status === "open" && f.options.length >= 2);
|
||||
closed = all.filter(f => f.status !== "open");
|
||||
render();
|
||||
}, () => { banner.hidden = false; banner.textContent = "The desk's store stopped answering. Reload to try again."; });
|
||||
db.collection("answers").onSnapshot(snap => {
|
||||
for (const d of snap.docs) { const v = d.data(); if (v && typeof v.choice === "string") answers[d.id] = v; }
|
||||
render();
|
||||
}, () => {});
|
||||
})();
|
||||
</script>
|
||||
BIN
tools/fork-desk/poses/libby/amused.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/annoyed.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/concerned.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tools/fork-desk/poses/libby/determined.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/happy.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/neutral.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tools/fork-desk/poses/libby/research.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
tools/fork-desk/poses/libby/schema.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
tools/fork-desk/poses/libby/shush.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
tools/fork-desk/poses/libby/stamp.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/surprised.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
tools/fork-desk/poses/libby/thinking.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |