Fork desk: forks go to one standing artifact the operator answers on #24
Fork desk: the standard is Nibu's format, one Persona per batch chosen by the work
desk.html follows the Aetheria Self's Nibu desk (questions collection, one booth character that follows the viewport and changes mood on interaction), with the Persona read from desk/config. make-poses.py maps a Persona's pose sheet onto the page's nine moods; Libby and Nibu are built. README, INDEX and SKILL.md follow. Rulings eureka-substrate:ruling:fork-desk-nibu-format and fork-desk-presenter-follows-work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4
SKILL.md
|
|
@ -249,8 +249,8 @@ 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,4 +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) |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -1,45 +1,61 @@
|
|||
# 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.
|
||||
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.
|
||||
|
||||
- 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).
|
||||
- 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 `forks`, doc id = the mind id
|
||||
(`<campaign>:question:<label>`, or the follow-up or ruling id when no question exists):
|
||||
`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 |
|
||||
|---|---|
|
||||
| `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` |
|
||||
| `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") |
|
||||
| `persona`, `pose`, `say` | who asks: a pose name under `poses/<persona>/`, and one line in their voice |
|
||||
| `flag` | optional: marks the card provisional, with the reason |
|
||||
| `order` | sort key |
|
||||
| `status` | `open`; Self sets `closed` plus `ruling` (the admitted id) after admitting |
|
||||
| `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).
|
||||
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`.
|
||||
`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`.
|
||||
|
||||
## Poses
|
||||
## Personas
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -1,249 +1,336 @@
|
|||
<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">
|
||||
<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 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>. */
|
||||
/* 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: #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;
|
||||
--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;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
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%;
|
||||
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: 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; }
|
||||
.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); }
|
||||
|
||||
.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); }
|
||||
.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); }
|
||||
|
||||
.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); }
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
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: 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 (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) { * { transition: none !important; } }
|
||||
@media (prefers-reduced-motion: reduce) { .portrait img.pop { animation: none; } .opt input::before { transition: none; } }
|
||||
</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>
|
||||
<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>
|
||||
// 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";
|
||||
// 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 = {};
|
||||
|
||||
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 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 el(tag, attrs = {}, ...kids) {
|
||||
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)) {
|
||||
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);
|
||||
for (const c of kids) if (c) n.append(c);
|
||||
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 })));
|
||||
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(f => el("li", { text: f.label + (f.ruling ? " → " + f.ruling : "") })));
|
||||
document.getElementById("recorded-list").replaceChildren(...closed.map(q => el("li", { text: (q.qid || q.id) + (q.ruling ? " → " + q.ruling : "") })));
|
||||
paint();
|
||||
}
|
||||
|
||||
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 }))));
|
||||
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;
|
||||
}
|
||||
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;
|
||||
document.getElementById("answered").textContent = n;
|
||||
document.getElementById("of").textContent = "of " + questions.length + " answered";
|
||||
return n;
|
||||
}
|
||||
|
||||
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 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.");
|
||||
}
|
||||
|
||||
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() };
|
||||
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.doc("answers/" + f.key).set(body);
|
||||
delete document.getElementById("fork-" + f.key).dataset.dirty;
|
||||
answers[f.key] = body; paint(f); renderHeader();
|
||||
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) {
|
||||
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; }
|
||||
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.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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
render();
|
||||
setPersona(persona);
|
||||
(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");
|
||||
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();
|
||||
}, () => { banner.hidden = false; banner.textContent = "The desk's store stopped answering. Reload to try again."; });
|
||||
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(); if (v && typeof v.choice === "string") answers[d.id] = v; }
|
||||
render();
|
||||
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>
|
||||
|
|
|
|||
31
tools/fork-desk/make-poses.py
Normal 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])
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 38 KiB |
BIN
tools/fork-desk/poses/libby/idle.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 37 KiB |
BIN
tools/fork-desk/poses/libby/note.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 37 KiB |
BIN
tools/fork-desk/poses/libby/triumph.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
tools/fork-desk/poses/nibu/concerned.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/fork-desk/poses/nibu/determined.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
tools/fork-desk/poses/nibu/happy.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/fork-desk/poses/nibu/idle.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/fork-desk/poses/nibu/neutral.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
tools/fork-desk/poses/nibu/note.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
tools/fork-desk/poses/nibu/surprised.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/fork-desk/poses/nibu/thinking.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/fork-desk/poses/nibu/triumph.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |