Seven owner-triggered steps, executed by fresh CC sessions (Opus 4.8), one at a time. Each step below is a self-contained contract: a cold session reads §0 (the protocol) + its own step card and ships without any other context. Written 2026-07-21 by the design session; supersedes steps 8–11 of the host-moves plan (steps 1–7 there are shipped history). Status pills update in the same commit as each ship.
CLAUDE.md(auto-loaded)— the dev loop, deploy policy, commit discipline.lib/PRESENTATION.md + the existing dock/capsule idioms in lib/room-ui.html.Room, persisted in state.json)and becomes an EVENT only at reveal. By construction — no filtering, no flags on events. Precedents: self.gate.subs, self.notes._record_turn so replay order = live order. Never strip working grammar from model memory.extract_panel_gates)+ one row in extract_panel_blocks + one _apply_panel_* called in drain_turn's bookkeeping section + a PARSE_STATS counter + capsule branches in replay() + payload in _replay_of + an SSE publish in _broadcast_turn. Outside-<speak> tag salvage comes free via extract_outside_blocks._patient_wake(never instantly — the tapper may want to speak).#tableDock, z-ladder 20)= live state · the stream = compact record capsules(.joincap family)· the composer zone = your pending action. Anchor floating UI on live getBoundingClientRect, never on measured CSS vars(the v488 lesson). Every dismissible layer registers the back gesture(registerBackLayers).build_system_blocks; the # OPERATING THE FURNITURE worked example is why personas comply — if your step adds grammar, extend the example, don't just describe.<board title="Score"> …the room's language: 比分)— never Chinese-only(it starves English rooms: 37% vs 82% tool compliance)and never English-only(the zh twin is the proven crutch for the owner's rooms). Reasons: model-switch resilience + English users. UI strings stay English-as-key i18n(unchanged).USER_TOOLS + room_tool_access() + a user_* route)— and its acceptance is an all-human room: the tool must work end to end with no persona seated. A tool only a persona can reach is half-built. Corollary: whatever a guest arms is THEIR move — the panel's riding note must say so, or the host narrates a move it never made.rooms-dev/<room>/state.json → history and look at the raw assistant text first(the Holmes law).rgt/rcl batteries in lib/smoketest.py — fake _call_anthropic, drive Room methods directly); python lib/smoketest.py must end SMOKE: PASS.t("…") calls + zh entries in lib/i18n.js(strict JSON, no comments); python lib/i18n_audit.py clean — remove stale keys you retire.const BUILD in lib/room-ui.html); bump BUILD + CACHE in lib/sw.js + any ?v= you touched. Never pre-assign.docs/style-guide.html: add/extend the entry for any new visible surface.git commit -- <files>; multi-line message via git commit -F -); git fetch origin before push; push to main; NO box deploy — the owner batches deploys.Get-NetTCPConnection -LocalPort 8011 → Stop-Process), relaunch with MAD_DB_PATH=app-dev.db MAD_ROOMS_DIR=rooms-dev(scripts\restart8011.bat or the preview MCP config mad-dev-8011). Server-side changes need a restart to take effect.INSERT INTO sessions(token, user_id, expires) into app-dev.db(test users: qa-amy=u7, qa-ben=u8), cookie mad_session=<token>. Create room → POST /api/rooms(cast + title)→ POST /api/rooms/{id}/open → members by username → /say, then poll /replay. Panel calls run on DeepSeek — a handful per probe is fine.| # | step | size | needs | status |
|---|---|---|---|---|
| T1 | the clock — standalone wall-clock furniture | S–M | — | shipped v554 |
| T2 | the tally — server-counted votes + the anon knob | M | — | shipped v559 |
| T3 | the deal — deck × safe × the private flip | M–L | — | shipped v562 |
| T4 | the touch drawer — humans operate the tools | M | T1, T2 | shipped v564 |
| T5 | cards — the session runtime + three cards | L | T1–T4 useful, not required | queued |
| T6 | the floor — mute/unmute inside sessions | S–M | T5 | queued |
| T7 | the door — sub-rooms + the outcome bridge; the werewolf card | L | T5 (+T1·T2·T3·T6 for the finale) | queued |
| T-UI | the muscle-memory redesign — chip strip · live card · badges · the animation grammar | M–L | T1–T4 shipped; never concurrent with another session editing room-ui.html | shipped v569(D1–D4; D5 rides T5) |
Goal: the meeting room's wall clock — set, watched by everyone, rings on the record. Absorbs the old plan's step 9 (wake) for the in-room case.
Grammar: <clock min="5" label="自由讨论"/>(min 1–120, integer; label optional ≤30 chars)· <clock away/> cancels. One clock at a time; a new set replaces (capsule notes the replacement).
Server contract: state self.clock = {end_ts, label, by, by_slug, set_ts} in state.json; events clock_set / clock_clear / clock_ring(capsules: ⏱ set ·「⏰ 时间到 · 自由讨论」). The ring: a server threading.Timer at set time → under the room lock, mint clock_ring, clear self.clock, queue a cue turn([The clock you set(自由讨论)has rung — the whole room heard it. The phase it timed is over; move the game to its next step.], roll_cue-style flag so the loop guard applies)+ _wake. Restart-safe: on Room load, re-arm the Timer if end_ts is future; if past(missed while down)ring once immediately. Guard the Timer against the room being closed/gone.
Client contract: dock slot #clockPin(zone order top→bottom: board · gate/checklist card · clock · die); renders label · mm:ss counting down locally(client computes from end_ts; no server ticks), pulses at ring. Rides rep.clock + SSE "clock"(set/clear payloads). Capsules in stream + replay.
SP: a # THE CLOCK section(set it for timed phases; the ring returns the floor to you; never narrate a countdown yourself — the room watches the real clock; <clock away/> if the phase ends early)+ one line in the worked example.
Anchors: extract_panel_boards(the extractor to copy)· _apply_panel_board(the applier shape)· board_state/rep.board(projection + payload wiring)· renderBoardPin/syncDock(dock slot)· _patient_wake/_wake.
Accept: set → dock countdown on two clients + capsule; ring → event + ONE cue turn(live probe: persona sets a 1-minute clock on request, rings, host reacts once); away; replace; restart with future end re-arms; restart past end rings once; smoketest battery ≥6 checks.
Don'ts: no cross-room/offline scheduling(that's the notifications rail's business, later); no per-user clocks; no server countdown ticks over SSE.
Goal: structured ballots whose COUNT is a server fact — arithmetic leaves the model's hands. Sealed-until-fire(our differentiator vs WhatsApp/Telegram polls: no live-count anchoring).
Grammar: <vote options="加辣, 不加辣, 弃权" anon/> — 2–12 options, each ≤40 chars; optional anon; collects all current humans(for= lists later if a card needs them)· <vote close/> fires early with what's in.
Server contract: implement as a gate KIND — self.gate gains {"kind": "vote", "options": […], "anon": bool}(default kind "text" = the existing circle, untouched). A vote does NOT suppress chat: room_say routes to the circle only for kind text; during a vote, messages and panel turns flow normally — ballots are TAPS. Route POST /api/rooms/{id}/vote_tap {option}(member check; re-tap revises; stored in gate.subs keyed str(uid) — the read-path law, nothing in events until fire). Fire = all-in(_gate_complete reused)or host close or the card's two-tap release. At fire: ONE vote_result event carrying counts: {option: n}(+ voters: {option: [uids]} ONLY when not anon; when anon the author map is DISCARDED at fire — it must not survive in state or events)+ capsule「📊 加辣 4 · 不加辣 2」+ ONE cue(counts as facts; meaning is the room's; the boundary law wording — see the collect-roll cue).
Client contract: the gate card renders the ballot: option buttons(my pick highlighted; re-tap to change), voter chips ✓/… , n/m, Release. No live counts before fire. After fire: the result capsule; non-anon voters listed compactly in a details sheet(reuse the reactions-sheet idiom). Replay + SSE("gate" payloads gain kind/options; "vote" result push).
SP: extend # THE CIRCLE with the vote form(when to use a vote vs a sealed text collect; anon for honesty questions; never reveal or guess at running counts).
Anchors: the whole gate battery: extract_panel_gates · _apply_panel_gate · gate_payload/gate_submit/gate_detach/gate_finish · the gate card renderer renderGateCard · room_gate_release.
Accept: vote arms the ballot card; taps revise + dedupe; chat + panel turns flow during the vote; all-in fires counts-as-facts + one cue; anon leaves no author trace in state.json or any replay after fire(write the test); close + release paths; restart mid-vote survives; live probe: persona calls a vote in natural language, two humans tap, host reads the counts once.
Don'ts: no live tallies; no server-declared "winner"(the count is the fact; the majority's meaning is the room's); don't touch the text circle's suppression semantics.
Goal: the face-down deal(toolbox §5b): a deck dealt privately — fair by shuffle-at-arm, private by delivery, revealable with proof.
Grammar: <roll deck="狼人×2, 预言家, 平民×3" for="all" into="safe"/> — deck = comma list, optional ×N multipliers, total ≥ collected players(extras stay undealt), fewer → refuse(malformed = nothing happens, never a partial deal). deck without into="safe" deals face-UP onto the table(public assignment — turn order cards). for="all" or a uid list; deck requires a collect(≥2 hands).
Server contract: shuffle at ARM: the full assignment {uid: card} is drawn(SystemRandom.shuffle)when the tag applies, stored in self.deal = {offer_lid, by_slug, cards: {str(uid): {card, seen: bool, ts}}, dealt_ts} — state.json only(read-path law; the roll_offer event carries the deck SPEC and hands, never the assignment). The GM's copy rides a per-turn note([The deal(dealt HH:MM, server-witnessed): Dan=狼人 · CW=平民 …])while a deal is live — it binds his narration like sealed notes bind rulings. Tap = the pickup(route roll_tap, collect branch): marks seen, returns the tapper's own card in the response; the room's chips tick「已看牌 n/m」. All-seen → checklist fires: ONE cue([Everyone has seen their card. You know the deal; they each know only their own. Proceed.])— _patient_wake. Reveal: <reveal who="u3"/>(extends the existing reveal grammar)opens that player's deal entry as a deal_reveal event/card — content + dealt_ts +「发牌后未曾改动 ✓」. Face-up variant: assignments post as public facts at all-seen(or immediately? — immediately: no pickup needed, it's public; the chips become the record).
Client contract: the hand slot shows a face-down card(🂠 + deck label)when I have an unseen card; tap flips it for my eyes only(a private card rendered from MY replay payload rep.deal_mine / the tap response — held-bubble family styling; persists across reload). Others see chips on the gate/checklist card. The reveal card reuses the envelope reveal idiom(.env-card)with the deal proof line.
SP: extend # SERVER DICE with the deck + delivery(when to deal face-down; your copy binds you; reveal with <reveal who="uN"/> at elimination/game end; never state a player's card the safe hasn't revealed — the timestamps prove it either way)+ the worked example gains a deal line.
Anchors: extract_panel_rolls/_ROLL_ATTR_RE(add deck/into attrs)· _apply_panel_rolls collect branch · tap_roll/_collect_cast · self.notes riding-note idiom(_notes_note)· renderHandDie(the hand slot)· renderNoteReveal(the proof card)· gate_payload viewer-filtering(the mine pattern for private payloads).
Accept: multiset exact(deal 狼×2/民×3 to 5 → exactly 2 wolves — write the loop test); assignment timestamped BEFORE any tap; privacy: another viewer's replay contains no trace of my card(write the test); the GM note carries the map; pickup chips + all-seen cue; reveal who carries dealt_ts; face-up deck posts publicly; restart mid-deal survives(unseen cards still flippable); live probe: a 3-player 身份 deal with two minted users + the persona GM narrating from its copy.
Don'ts: no draw-at-tap(the shuffle is at arm — the whole fairness claim); the assignment never enters events until revealed; don't build persona-held cards(persona-as-player is explicitly later — ruling ⑤).
Goal: the humans' door into the same mechanisms — touch, never syntax. Free rooms default-open; card sessions will later defer to the card's grants(leave the hook: a single room_tool_access(room, user, tool) predicate, default True, that T5 can tighten).
UI contract: a tools door on the composer(sibling of the emoji door — study #emoBtn's placement discipline; do NOT crowd the inputrow: one ➕/🎲 button)opening a sheet(dialog-styles: sheet type; back-gesture registered; i18n'd)with four actions: 掷骰(quick picks 1d6 · 1d20 · 抛硬币=1d2 · custom NdM)· 发起投票(options editor, 2–12, anon toggle)· 封存笔记(text, ≤300)· 设个闹钟(1 · 5 · 10 min · custom). Every action lands as the SAME room furniture with a HUMAN attribution capsule(「Dan 掷出 …」「Dan 发起了投票」).
Server contract: routes POST /api/rooms/{id}/user_roll {spec}(rolls immediately — the user's own witnessed draw; table + record capsule attributed to the human; budget: this IS the contract grant — default 1 per arm doesn't apply to self-serve rolls, so rate-cap instead: ≤1 per user per 30s, 429-style soft error)· user_vote {options, anon}(opens a T2 vote, opener=human — any member may close it)· user_note {text}(seals into the safe attributed to the user — visible capsule; the owner of a note can reveal their own oldest via the drawer)· user_clock {min, label}(T1 clock, by=human). All uses append normal events → the panel reads them as records(SP gains one line:「guests may operate the room's tools; their uses arrive as records — react as a host, never re-roll or re-count」).
Anchors: the emoji door(#emoBtn)+ the sheet/dialog idioms · _run_roll · T1/T2 servers · registerBackLayers · lib/PRESENTATION.md §dialog styles.
Accept: all four actions from a minted user session, attributed capsules, panel reacts sanely in a live probe(host comments on a human's roll without re-rolling); rate cap enforced; the access predicate exists and is called everywhere; back-gesture + i18n audits clean; style-guide entry.
Don'ts: no user mute/floor(session-only, T6); no user <tags> anywhere — humans never see syntax; don't let the drawer grow beyond the four(the tally sheet is where more belongs later).
Goal: scenarios ship as CARDS(plain-language programs — the CC-skills analogy): procedure + rules + grants, loaded per-session. The card is where meaning lives(the boundary law's other half)— pinned rules the host re-reads, an opener that makes the first move mandatory, a closing move that clears the table.
The card format: prototype/cards/<slug>/card.md — YAML frontmatter: name(en)· 名称(zh)· players: {min, max} · tools: [dice, board, safe, vote, clock…](display + a soft grant list)· version. Body(English base, exemplars as English-first pairs with a Chinese twin — the language law, §0): OPENING MOVE(the exact first-reply obligation, tags included — the compliance pin)· THE RULES(judging tables, scoring, what a tie does — the contract, stated once)· EACH ROUND(the loop in the shared-skeleton shape)· ENDS WHEN(termination + the closing move: declare · reveal what must open · away every tool). Keep a card ≤1 page; write prototype/cards/README.md(the authoring guide, brief).
Server contract: card registry(card_dir/list, repo-first like personas; no DB rows — disk is the registry); room session state self.session_card = {slug, started_ts, started_by}(state.json; one at a time); loading injects the card body as a block in build_system_blocks materials(cache-stable per room — a session start/end is a prefix rewrite, acceptable and rare)+ a session-start cue turn:([The session「比大小擂台」begins now. Execute the card's OPENING MOVE in this very reply — the tags, not a description.]). Ending(host <session end/> when the card's ENDS-WHEN is met, or any member via the chat menu): cue demands the closing move, then clears session_card + capsule. Events session_start/session_end(capsules with the card name). The room_tool_access hook from T4 now consults the card's grants during a session.
Client contract: starting = the chat-menu(⋮)gains「开一局 / Start a session」→ a simple picker(name + players + tools chips; the deck-UI shelf can stay minimal — the encounter principle warns against a browsed mall, and the real discovery door comes later); a slim session banner(the dock's top edge:「比大小擂台 · 进行中」+ end affordance); capsules in stream/replay.
The three cards(acceptance vehicles, authored in this step): bi-da-xiao(比大小擂台 — randomizer × checklist × board; the toolbox §4 loop verbatim)· rps(石头剪刀布 — safe × checklist × board; host-as-player seals)· trivia(知识擂台 — the Holmes game formalized: safe'd answer keys, labeled seals, reveal-before-ruling).
Anchors: build_system_blocks(where the card block joins materials — mind the §8 cache comment there)· persona_dir/library_descriptor(the registry idiom to copy)· queue_entrance(the cue-turn shape)· the chat-menu + sheet idioms · toolbox.html §4(the loop the card body should mirror).
Accept: start 比大小 in a fresh room → the host's FIRST reply arms board+dice(the card opener beats persona variance — probe with a tool-shy persona, e.g. isaac-newton, not just Twain); a full 3-point game runs with correct board discipline; session end clears the table; trivia card: seals labeled + reveal-before-ruling honored; RPS card: host seals with the gate; session capsules in replay; smoketest: registry + session state + cue mechanics(no live-model asserts).
Don'ts: cards are DATA — no python per card, ever; don't build a card marketplace/browser(the encounter principle — a picker is enough for now); don't let card text restate tool mechanics(the SP owns the physics; the card owns THIS game's meaning — single-source).
Goal: the talking-stick — server-enforced mute/unmute, existing ONLY inside a running card session(the consent container; ruling ⑧'s carve-out).
Grammar: <floor mute="u3" reason="出局"/> · <floor unmute="u3"/> · session end auto-unmutes everyone(hard invariant).
Server contract: self.muted = {str(uid): {reason, ts, by_slug}}(state.json; refuse the tag entirely when no session runs — log to parse stats, apply nothing); room_say soft-rejects a muted member({"error": "muted", "reason": …}; asides/DMs untouched — the mute is THIS room's floor). Reactions stay allowed(the spectator's nod). Events floor_mute/floor_unmute — visible attributed capsules(「🎙 李安 请 Dan 旁听 · 出局」). Payload rides rep.muted + SSE "floor".
Client contract: muted-me → composer disabled with the reason banner(「你已出局 · 旁观中 — 会话结束自动恢复」), reactions still tappable; others see the capsule + a small 🔇 by the member in chat-info. Session end restores everything.
Anchors: the T5 session state · room_say's guard ladder · the composer-disable idiom(the PEEK read-only mode is precedent)· chat-info member rows.
Accept: mute blocks say(server AND composer state)while reactions work; unmute restores; session end auto-unmutes(including after a restart mid-session — write the test); the tag outside a session applies nothing; capsules attributed; i18n.
Don'ts: no human-initiated mute(not even the session starter — v1 the host only); never block leaving the room or ending the session(the human outranks the furniture); no mute in free rooms, ever.
Goal: the GM opens a scoped side-room(the den, the game-scoped whisper), seats members, and its results — only its results — flow back. The most abuse-sensitive step: read toolbox.html §10 before designing, and keep every power inside the session.
Grammar: <room open="狼人夜谈" seat="u3,u5" clock="5"/>(session-only, like the floor; seats must be current members of the parent; optional clock auto-set in the child)· <room close="狼人夜谈"/>(or it closes with the session). A pair-sized variant IS the whisper(seat one human).
Server contract: the child = a real Room(the proven machinery)created with: the parent's cast host(same GM persona), the seated humans only, dm="", a context brief injected as its opening pending note(the parent card + GM instruction +「this side-room reports only its OUTCOMES to the main room」), and a parent link self.parent_room. Seated members get the room in their chat list + a notice(consent = the session they already joined; still rate-cap: ≤3 open children per session). The outcome bridge: when a checklist/vote/deal FIRES in a child that has a parent, the firing's result(the vote counts · the collect facts — never the discussion)posts to the parent as a bridge event + a bracketed note the GM reads next turn([From 狼人夜谈: the vote → 阿明 4 · 小美 1]); a capsule marks it in both rooms. Child lifecycle: closes(archived for members)at session end; the parent GM's turns never read child content beyond bridge events.
Client contract: the child appears as a normal chat(its origin capsule names the parent:「由『狼人夜谈』· 从主局开出」); bridge capsules in both rooms; the parent's chat-info lists open children.
THE FINALE — the werewolf card(authored here, the acceptance): 6 seats(1 GM persona + 5 humans min 3), composing T3(the face-down deal)→ T7(the den, T1-clocked)→ T2(the den vote, bridged)→ the announce → T6(the floor mutes the eliminated after last words)→ day vote(T2 in the parent)→ ENDS WHEN + the closing move(reveal all deal entries from the safe — the proof moment). Run it live with two minted humans + shortened phases; the full-table run is the owner's.
Anchors: RoomManager.create/invite/queue_entrance(room birth + pending briefs)· notify/room-invite notices · the checklist/vote/deal fire sites(where the bridge hooks)· toolbox.html §5 + §10.
Accept: GM opens the den seating listed humans(capsule + list entries); den discussion isolated(parent replay clean — write the test); den vote fires → bridge event + note in parent, GM announces from it; child count cap; children close at session end; the werewolf card's night 1 runs end-to-end live(deal → den → vote → bridge → announce → mute → last words).
Don'ts: no free-room door(session-only in v1 — the consent design for open use is a separate future ruling); the bridge carries outcomes only — never messages; don't build persona wolves(ruling ⑤: humans play; the GM hosts).
Goal: make the tools feel like furniture on screen, not a stack of widgets. The evidence base is toolbox-ui-study.html — read it FIRST, whole: the six field laws, the gap table, and the before/after wireframe are this step's spec. Owner verdict driving it:「far from muscle memory」. The verified anti-pattern we currently are: a four-strip pinned HUD(board · ballot/circle card · clock · die)— no verified app in the field ships one.
Scope — the study's D1–D4(D5, the earned takeover, is EXCLUDED: it needs T5's card sessions and lands with them):
prefers-reduced-motion throughout.Hard constraints: client-only(lib/room-ui.html + sw.js + i18n.js + the style guide; touch run_room.py ONLY if a payload provably lacks a field — record it in the findings log). Every projection the chips/cards render from already rides replay + SSE(rep.board/clock/dice/gate)— reconcile-on-reload must survive. The z-ladder(topbar 36 · composer 30 · strip 20); anchor floating elements on live rects(the v488 law); registerBackLayers for the expanded chip card; house glyphs via tEmo()/TOOL_EMO for every tool face; i18n on every new string; mobile viewport first(375px).
Anchors: #tableDock + syncDock + the four render fns(renderBoardPin · renderGateCard · the clock pin · renderDiceTable)· renderHandDie/positionHandDock(keep)· the capsule builders(leave alone)· lib/PRESENTATION.md · the furniture entries in docs/style-guide.html(rewrite them as you ship).
Accept(the stack test): a mid-game room with board + open ballot + running clock + armed die simultaneously shows ≤ one thin strip(~40px)of pre-chat chrome, the ballot riding the stream and re-floating past three new messages; chip tap → card expand → back-gesture collapse, all at paint level on a 375px viewport(measure with elementFromPoint — the T1 finding: the pane's screenshots hang, animations freeze at frame 0; getAnimations().forEach(a=>a.finish()) before measuring); a reload mid-everything reconciles every chip and the live card; reduced-motion audit clean; BUILD+SW bump; style-guide furniture section rewritten; the study page's D1–D4 marked shipped in ITS foot + this page's pill + findings, same commit.
Don'ts: no D5 takeover; no new server mechanisms; don't touch the sealed-bubble visuals' DESIGN(the owner has a deferred design pass of their own — restyle only what the chip/card move forces); don't remove the record capsules(the stream stays the durable history); don't build a tool "menu" into the strip(creation stays in the T4 drawer).
Each session appends a dated entry here(in the ship commit): what was built, the version, traps hit, decisions made where the contract was silent, and anything the next session must know.
⚠ 「A persona cannot add a title to the poll, humans can」was a BUG REPORT; I read it as a ruling. q= had been accepted from the panel since v559 — what never existed was any instruction to WRITE one, so the model never did. v571 then removed the acceptance, which turned a missing lesson into a policy and made the reported problem permanent. Reverted; the SP now asks for the title outright(<vote q="the question" options="…"/>, marked「not optional」, English base + zh twin). The next persona poll on the live path came back titled「茶还是咖啡」. The tell:「cannot」described the OUTPUT, not the API. When a report says a persona「cannot」do something, check whether the SP ever taught it before touching the parser — and when a fix is「remove a capability」, be sure the capability was the problem.
The rest: the guest's editor left the drawer for a page(「Create poll」, renamed from「Start a vote」)on the .fwd-page rails. WhatsApp's two habits are the load-bearing ones — the option list adds its own next field(fill the last, one appears; empty the tail, the spare collapses), and every option carries a grip, with touch-action:none on the grip alone so a caret drag inside a field never moves the option. ONE draft object is the truth and the DOM is a view of it: a reorder is an array splice plus a repaint, never node moves that then disagree with what gets posted. Plus an Anonymous toggle, a Who votes row defaulting to everyone(an untouched picker sends nothing and the server reads that as the whole room — the common case costs zero taps), and a leave-guard. The count bar's fill dropped to 48% coral(72% for your own pick): at full strength a column of bars read as a warning rather than a count.
Testing note that cost a few minutes: the poll page measured as sitting entirely off-screen(left:375 on a 375px viewport)with .open set. That is the frozen-transition trap again — the pane does not composite, so transform:translateX(100%) → none never advances. Inject *{transition:none!important} before measuring any slide-in layer; with it the page sits at left:0 and every control hit-tests topmost.
Two of the six notes were one bug wearing two faces, and it is the lesson worth keeping. 「the opener's avatar is sometimes the 📊 emoji until you refresh」and「a multi poll shows circle ticks」were the same miss: vote_finish() returned by but not by_slug, by_uid or multi, and the SSE vote payload is what the live result card renders from. Replay was fine the whole time, because replay reads the EVENT — which had the fields. The live path and the replay path drew the same card from two different dicts and only one of them was complete, so「refresh fixes it」was the tell, and I should have read it as「the broadcast is thinner than the event」the moment it was reported. Anything rendered from both an event and a broadcast wants one field list, checked against both.
Also: the 「X opened a vote」pill retired(the card announces itself, wears its opener's face and stays as the locked result — a pill above it was an announcement of an announcement; the vote_open event is untouched, only the render went); 16px between options; a persona's face drawn the way a message head draws it(seat colour as BACKGROUND under a white monogram — verified pixel-identical against a real head; the coloured-glyph-on-nothing form was the odd one out in the app); and no title from a persona(the SP asks for the question in the character's own words in the same reply, so a q= says it twice — once in voice, once as a label it never wrote. Verified through the live model path: asked explicitly for a title, Tess's poll came back with none.)
「In desktop mode, when I focus on the composer, screen will flash once.」It does not reproduce under instrumentation: at 1200×860, focusing #ta produced zero attribute mutations on <html>/<body>, no --app-h write, and no change in scrollTop, composer height, chat top or shell height sampled across a full second. The keyboard subsystem is innocent on that path by construction — preShrink returns immediately when !IOS, and write() takes the clearShell() early return when !IOS && !kbdUp && !occupant. The live suspect is the frosted glass: .composer carries backdrop-filter: blur(24px) saturate(1.8) and .inputrow:focus-within transitions its border colour — any paint inside a backdrop-filtered element re-rasterises the whole blurred layer, and on some compositors that is a visible one-frame flash of everything sampled behind it. ⚠ The browser pane does not composite frames at all(the same property that kills screenshots and scroll events), so no probe here can see a flash, and a fix shipped on that basis would be a claim I cannot back — exactly the blind-probe failure the visual-verification rule exists to prevent. The discriminating test, 10 seconds in the owner's own browser: run document.querySelectorAll(".composer,.topbar").forEach(e=>e.style.backdropFilter="none") in the console, then focus the composer. Flash gone → it is the frosted layer, and the fix is to stop painting inside it on focus(move the focus ring to an inset box-shadow on the inner field, or drop the border transition). Flash still there → it is not the blur, and the next suspects are the theme's color-mix background or the platform's own caret/IME layer.
Two of them were one rule and one of them was my bug. ①+③ A vote is final(single and multi)— which also closes the revise-after-reveal hole v570 left open on purpose, so the anti-anchoring story is now complete: the gate stops you being swayed before you vote, the lock stops you acting on what it showed you after. Refused in vote_tap, not greyed in the UI, and a re-submit of the SAME set stays an idempotent no-op(a client retry must never read as a change). ② 「don't squeeze the bubble」— a real bug: lt-pop scaled the live card to .985 on every gate event, i.e. every time anybody tapped, so your own vote made the thing under your finger flinch. It violated D4 and the never-relayout-under-a-touch rule at once, and I had shipped it 24 hours earlier without noticing, because I only ever watched the card from the outside. The pop now survives only where the user asked to be shown the card. ④ Options may be sentences(40 → 120 chars, rows wrap)— and the two regex caps that would have clipped a long list first had to go up with it(_VOTE_TAG_RE's attribute run, _VOTE_OPT_RE's value), which is the kind of ceiling that only shows up on the second-longest input. ⑤ The closed poll IS the bubble, locked: it stopped vanishing into a one-line capsule and now closes in place. The load-bearing decision is that it renders from the vote_result event through the same row builder as the live card — the live card is presentation and dies with its container, the result is the durable record that must survive a reload and a replay. Identical shape from one builder is what makes two different DOM nodes read as「the same bubble, locked」.
Model-facing strings had to move with the behaviour. The SP still promised「anyone may re-tap to change theirs」and the riding note still claimed「no running count exists, for you or for the room」— the second one had been false since v570(a voter sees one; the panel never does, because personas do not vote). Both now say what is true, which matters more here than usual: the panel builds turns out of that note.
Owner brief off a WhatsApp reference, four changes, design page: poll.html. The card belongs to its opener(「Ben started a poll」; by_uid joins the gate payload and the sealed circle gets the same line), each voter's face sits on the option they picked with a bar behind it, a 「waiting for」 row names who is still out, and multi-choice lands(<vote … multi/> + a drawer toggle; subs store a SET, {ii: […]}). Holding it all up is THE REVEAL GATE — one branch in gate_payload(viewer_uid) that withholds counts/by_opt from anyone who has not voted. 14 new smoketest checks(666 total), 6 i18n keys.
/gate body ends …"in":[7],"mine_ii":[]. Amy's vote appears in in(WHO)and nowhere else(never WHAT).gate SSE is viewer-LESS, and that is load-bearing. One payload fans out to a whole room; only some of them have earned the numbers. So the broadcast can only ever be the un-revealed shape — which it already was, because every publish site calls gate_payload() with no argument(safe by construction, and the docstring now says so in as many words). The tapper's standings ride their own tap response; every other voter re-reads a new GET /api/rooms/{id}/gate. This is T3's pattern for a dealt card, reused: viewer-scoped data never touches a broadcast.counts being absent, so the line is present exactly when the dashes are.DEAL_PEEK's rule).subs is what「has voted」means and the reveal gate reads it; a person who could un-vote could read the standings and leave no trace of having looked.{options: […]}, where a single-choice tap is the one-element case — and a single-choice ballot keeps only the FIRST index, so a stale client cannot smuggle three picks onto a one-pick poll. {option: i} is still accepted, because a service-worker-cached v569 shell keeps voting through a deploy.n counts heads, not picks. On a multi ballot the per-option counts sum past the number of voters(correct, and the cue tells the panel so in as many words)— but「n of m people voted」computed from that sum would print a number bigger than the room.options= — a live bug, found by trying to add a second flag. <vote options="anon, named"/> armed an anonymous ballot: a poll about anonymity became one. Invisible in practice because it only fires when a room votes about the very word — and multi would have inherited it(「multiple dates, one date」). Both now read through _bare_flag(), which masks quoted values before searching. The masking must spare the flag's own value, or anon="false" reads as present-and-therefore-true — the smoketest caught exactly that on the first attempt. Any future bare-flag attribute wants this helper, not its own regex._sub_picks() reads both shapes. Through v569 a vote was {i: 2}; it is now {ii: [2]}. There was an open ballot in a dev room at upgrade time — there will be one on the box too. Same class as v555's minutes-on-disk.#voGo.Two minted users(Amy u7 · Ben u8)+ Tess over the real HTTP routes, then at paint level on 375×812. The one that matters: with Amy's vote already cast, Ben's card shows「—」on every option, empty bars and zero faces — and his /replay(1656 bytes)and /gate bodies contain no counts and no by_opt at all. Also: the tapper's reveal arriving in their own tap response; a multi commit storing [0,2] and the counts then summing past the people(Fri 1 · Sat 1 · Reykjavik… — 3 picks, 2 voters); an anon poll revealing numbers and zero faces; ticking leaving all counts dashed; a reload restoring opener, ticks, counts, bars, faces and the waiting row; a cached v569 {option: i} body still voting; every control hit-tested topmost; console clean. python lib/smoketest.py → SMOKE: PASS; i18n_audit clean.
The result capsule is untouched — a poll that fires still collapses to the one-line count + the who-voted sheet, though the full bar card is now sitting right there and would be a straight upgrade for anyone who never voted. Revising after the reveal stays allowed(the anchoring returns, one step narrower)— reversible if it turns out to matter. And the vote-for-everything hole in the reveal gate is deliberately open: the counter is social(your picks are on every bar), and every mechanical fix punishes the honest multi-select case.
D1–D4, client-only — run_room.py was never opened. Every projection the redesign needed(rep.board/gate/clock/dice)already rode replay and SSE, which is the strongest evidence that the four mechanisms were architecturally right and only their placement was wrong. D1: #tableDock keeps its id and its rung on the z-ladder, but its four stacked slots are gone — it is now one .ts-row of .ts-chips(board · circle/ballot · clock · die, the toolbox's zone order)plus .ts-panel, an absolutely-positioned card that hangs OVER the chat. D2: a new .livetool node at the stream's tail holds up to two cards(#liveGate + #liveDie — a container and a die can both be waiting)and re-floats under every new message. D3: memberRow() replaces four hand-rolled chip builders. D4: the two die-rock loops became finite and a prefers-reduced-motion block kills every furniture beat. Net: ~120 lines of render code deleted(.board-pin, .clock-pin, .gc-chip, .td-cc and their JS are gone), 4 new i18n keys, 1 retired.
childList observer on #stream covers every path that exists and every path that will. It terminates by construction: our own re-append makes the host last, and last is the no-op branch.dice.state === "pending" ‖ gate.open, which also answers the cases the list didn't: a settled collect-roll leaves the stream(its capsule is the record), a finished deal(everyone has looked, cards still in hands)is state, not a pending move, so it collapses to a 🎴 chip. Without one predicate,「is the deal live?」 would have been answered differently in the chip, the card and the fly-to target..mainpane, which is position:fixed + transformed on narrow — a position:fixed scrim re-anchors to that transformed ancestor and its extent stops being the thing you reasoned about. A one-shot document pointerdown listener costs nothing, needs no geometry, and leaves the chat scrollable underneath. Dismissal is therefore four-way: tap-away · ✕ · a chat scroll · the back gesture.syncDock(), which rebuilds #tsRow's innerHTML — meaning every chip is destroyed and recreated once per second, so a chip can be re-created out from under a finger mid-tap and every :active state flickers. The tick now sets #tsClockV.textContent and toggles .soon, and falls back to a full repaint only if that node is missing.syncDock() reads all four and is called from every render fn in the file; ROOM_CLOCK, ROOM_BOARD and ROOM_GATE were lets declared 200–400 lines below it. That is the v477 kill exactly — moved up beside ROOM_DICE with the reason written down.scroll or rAF-driven event ever fires. A programmatic scrollTop change moved the scroller and fired zero scroll events — my scroll-to-close read as broken when the wiring was fine(proved by dispatching a synthetic Event("scroll")). Same family as T1's frozen-at-frame-0 animations. Verify event wiring by dispatching the event; verify layout by measuring.backStack arms nothing until a REAL user gesture. It only pushes history entries after a pointerdown/keydown/touchstart has reached window(deliberately — a pre-gesture pushState makes a skippable entry). A synthetic el.click() does not qualify, so history.back() in a probe pops the page's own navigation entry and the tab silently leaves the room. Dispatch a real PointerEvent("pointerdown", {bubbles:true}) first, then test Back — otherwise a working layer tests as broken and each retry eats another history entry..chat-item.click() — the handler is on the inner .ci-main button. My first room-switch sweep「showed」every room carrying the same four chips, which looked exactly like a projection leak across rooms; nothing had switched at all. Click .ci-main, and confirm the switch by reading #roomTitle before believing anything downstream.display: outranks the UA's [hidden]. The old .table-dock{display:block} meant dock.hidden = true never actually hid it(a hairline of border survived every empty room). Any new container that toggles .hidden needs its own [hidden]{display:none} rule — this file's own convention, followed by ~10 other components.flyDieToTable aimed at #tableDock; the throw now flies to #liveDie .tbl-die and only falls back to the chip. It also bails on a zero-size rect — a hidden strip used to give it a 0×0 destination and the ghost flew to the corner.The stack test, on a 375×812 viewport(two minted sessions — Amy u7 · Ben u8 — plus Tess, board + open ballot + running clock + armed collect-roll live at once): pre-chat chrome 35.5px with all four chips out, and 34.4px / 0px(hidden) as tools come down; the ballot and the die both riding .livetool as the stream's last child, still last after three human messages and two persona replies; a ballot tap moving 0/2 → 1/2 with the ✓ in the pill and the badge on my face within 30ms, reconciled by the SSE echo; a full reload restoring all four chips, my selected option, my badge and the live card's position; the die settling → its card leaving the stream and its detail(「1d6 · Amy 1 · Ben 4」)one chip-tap away; chip → panel → back gesture collapsing the panel and staying in the room; a room switch repainting the strip from the new room's own projections(1 chip)and an empty room hiding it entirely. Hit-tested with elementFromPoint(every chip, both cards, the option pills, the release button, the hand die topmost); zero infinite animations on any furniture surface and the reduced-motion block live in the CSSOM; all four chip glyphs are loaded house Twemoji. Wide layout(1100px)checked too: strip and panel start exactly at the sidebar's right edge. python lib/smoketest.py → SMOKE: PASS; i18n_audit clean; console clean throughout.
D5, the earned takeover(excluded by this contract — it needs T5's card sessions). The one-shot ceremony overlay is specified but deliberately unbuilt: D4 reserves it for exactly two peak moments — the deal flip and the unseal beat — and both deserve the owner's design pass first(furniture.html, and the cc-not-for-visual-form rule). .ts-phase, the strip's left end, is built and empty: setToolPhase(label) exists and renders; T5 need only call it. And the study's own open question stands — no measured effectiveness data exists for any of these patterns, so the first real telemetry(tool-arm rates, tap latency, whether anyone ever opens a chip's card)is worth more than another round of field reading.
A second door on the composer(a die outline, not the ➕ the contract sketched)opens a sheet with the room's four tools: 掷骰 · 发起投票 · 封存笔记 · 设个计时. Server side it is four routes(user_roll · user_vote · user_note(+user_note_reveal)· user_clock)over four Room methods, one shared _tool_door() preamble, and the contract's grant hook as a real module predicate — room_tool_access(room, uid, tool) over a USER_TOOLS tuple, so an unknown tool name is refused rather than waved through. Not one new event type, channel, capsule or replay branch was added: a guest's use rides T1/T2/T3's own machinery with a person's name in by and their id in a new by_uid. New: note_mine(viewer)(a count, never content)on /replay and the resumed /open, _vote_clean() split out of _vote_options() so a typed list and a tagged one become the same ballot, a THE GUESTS HAVE HANDS TOO paragraph in the worked example, 35 smoketest checks(27 unit + 8 over the real HTTP routes)and 31 i18n keys.
roll_result beneath somebody's standing offer(T3's own trap, from the other side); a ballot armed over an open container would release words the room was still writing — and「release-on-arm」is a host's judgement, not a tap's; a clock set while one runs would discard the host's timed phase, which the one-clock rule would otherwise hide. Each refuses with a named reason that reaches the person as a toast, and the drawer greys the row with a hint before they even try. A tap is not a request the room may quietly drop.self.notes — but by_uid keeps it OUT of _notes_note()(the panel is told a guest's note exists, with whose and when, and never what it says)and out of the panel's bare <reveal/>(which now takes the oldest of its OWN). Verified in the real megaprompt, not in a mock. Without this the drawer's third button would have been a leak dressed as a feature: a room where sealing a secret hands it straight to the one player who narrates..emo-btn despite copying its geometry — that class carries body.emoji-open styling, and a second button wearing it lights up whenever the OTHER door opens.pick="正面, 反面" would have read better and would have put UI-locale words into a room whose language is its own; the drawer stays free of anything the panel must then interpret.q=: without it a guest's ballot capsule reads「📊 A / B」and the room has no record of what was asked. Not a fifth action — the drawer stays at four.user_roll queues a cue and a _patient_wake — so putting its happy path in the smoketest would have had the suite reach for a live model 8 seconds later, on a machine whose .env holds real keys. Its refusals ride the HTTP battery and its success is unit- and live-tested instead. Every T5–T7 route that queues a cue has this shape; the question to ask of a new route is「what wakes up because of this?」threading.Timer is still not a daemon(T1's trap, third sighting). _patient_wake arms up to two minutes of non-daemon timers, so a room left mid-wait held the whole process open at shutdown for exactly that long. Fixed here. Anything that parks a timer must set daemon = True — grep for threading.Timer before you ship.getAnimations().forEach(a => a.finish()) on the sheet's own subtree, after opening it — finishing them before the open proves nothing). The screenshot path still times out(T1's finding, unchanged), so paint verification stays measurement-based.mad-ui-lang flips the pre-paint pass and then the boot's syncFromMe() puts it straight back — the zh render can only be checked by setting users.default_lang(and restoring it). Two minutes lost; worth writing down.mad-t4-8019 launch entry with the identical dev wiring(MAD_DB_PATH=app-dev.db + MAD_ROOMS_DIR=rooms-dev). The recipe's number is not the point; the wiring is. .claude/ is gitignored, so the entry is local.Smoketest 35 drawer checks, SMOKE: PASS; i18n_audit clean. Live on the dev server(Mark Twain, DeepSeek, two minted humans over the real HTTP routes): Amy rolled 1d6 → 5 from the drawer and Twain answered 「Five. *[a slow nod at the die]* Well, Amy — a five's a fine start for anything… What's the game?」 — the fact read, the person named, no re-roll. Ben opened a three-option ballot, both tapped, and the host read 「Two votes for a story, none against — that's the kind of election I like. Amy, Ben, you've got a minute-fifteen on the clock…」: the count as given, the guest's clock noticed, and no claim on a move it never made. The privacy acceptance was checked in the real megaprompt, not a mock — Amy's sealed line does not appear anywhere in state.json's history, while the guests-seal clause does(「Amy (06:58). You cannot read them…」); Ben's attempt to open it returned nothing sealed. All three refusals fired live(too soon / retry_in 30 · container open · clock running). Paint(375px, fresh shell, SW purged, light + dark, en + zh): the row lays out emoji 25 · tools 63 · text 99–307 · mic 311 with no horizontal overflow, the door wins the hit test at its centre, the sheet sits flush at 812, all four glyphs are house art(/emoji/1f3b2 · 1f4ca · 2709 · 23f0.svg), a real chip tap posted and rendered 「1d20 → 10 · You」 live over SSE with the human avatar on the dock, the rate cap toasted 「One at a time — try again in 14s」 with the sheet left open, an armed die greyed the chips with 「a cast is waiting」, the accordion opens one editor at a time, and Esc and the back gesture each close it(stack depth 2 → 1, aria-expanded back to false). In zh: 房间工具 · 掷骰 · 发起投票 · 封存笔记 · 设个计时, a seal capsule 「Amy 封存了一条笔记 · 15:04」 with the text nowhere in the stream, and the reveal card 「封存于 15:04 · 服务器存证 · ✓ 封存后未曾改动」.
「Whatever a persona can do, a human should be able to do. The test is: can a human start all these tools in an all-human room?」 — owner, same day. That ruling retires this contract's「don't let the drawer grow beyond the four」, and the answer to the test was no on five counts. The drawer now carries every mechanism the panel can arm: the die, the wheel(pick — the randomizer's second distribution, which v564 simply could not reach), a custom deck, the sealed round, the ballot, the envelope, the board, the clock, and the away that takes each of them down. USER_TOOLS is now the panel's mechanism list, by definition.
room_say's zero-persona relay(gap 9)returned before the circle branch, so in an all-human room — the one place a guest-opened round matters most — nothing was ever held: every answer posted the instant it was typed. The check now sits ABOVE the relay, with a comment saying why. Any behaviour gated on a panel turn should be asked「what does this do in a room with no panel?」 — the gap-9 early return is a fork most of this file never sees._deal_note() tells a seated panel the deal exists and that it does not know it(「YOU DO NOT KNOW who holds what, and neither does the dealer」), and the endgame door is consent-shaped —「亮我的牌」opens your own entry and no one else's, so an all-human reveal-all is everyone choosing to turn their card over.TOOL_CAPPED = ("roll", "note") now names the only two nothing else serializes — a roll queues a model cue every time, and a note has no guard at all. Ask of any cap: what does this catch that the state does not already refuse? And the discoverability half: while a deal is live the deal ROW carries 「收牌」 beside 「亮我的牌」, because the Deal button is greyed and its cure must not live further down a scrolling sheet.click.target === scrim fires on a gesture that merely ended outside the sheet — which is exactly what a mouse drag-select does the moment it strays a few px past the input's edge: press inside, release outside, and the click resolves to the common ancestor. The sheet vanished mid-selection, every time. Tap-out now requires the gesture to have BEGUN on the scrim. The reaction and vote sheets carry the identical latent bug and have never shown it — because neither holds text, which is why five months of sheets were fine and this one was not. ② The dead buttons were trap ② of the virtual-keyboard war. You type with the keyboard up, then reach for the commit button: pointerdown blurs the field, the keys leave, the shell grows back ~300px, every row in the sheet moves, and the browser cancels a click whose target relayouted under the finger. preventDefault on pointerdown — the emoji door's proven discipline — keeps focus and the caret exactly where they are and does no layout, so the click lands on a button that never moved. ③ And the scrim binds --app-h on a coarse pointer only: a height that depends on a var the keyboard subsystem both writes and clears will jump on a desktop that has no keyboard to make room for. The general rule this cost: a surface that accepts typing is not the same object as one that only shows things — inherit its furniture, but re-ask every interaction question. The stale-SW gotcha bit once here too: the pane served v565 while disk was v566, and two verified-absent listeners looked like two failed fixes._tool_who() resolves the cast everywhere: absent or empty = everyone(so the common case still takes no taps), a stale id drops silently rather than refusing the move, an empty pick refuses out loud. The picker is a row of member chips, all lit by default — you DESELECT to say「these three are playing」. And cancel is the containers' third door: release posts, close counts, cancel discards — which is exactly why its capsule carries the number(「Ben 取消了这一轮 · 2 份回答已作废」). A round that quietly swallowed two answers would be a way to make people commit and then bury it; two-tap, like every destructive move in this drawer.gate_submit held the words of anyone in the room, because until now「open」and「asked」were the same set. The circle silencing people it never invited is the one thing it was never allowed to do. It now refuses a non-participant and room_say falls through to an ordinary post — the composer strip was already scoped to g.need, so the client had been right about this all along and the server was not. Whenever a set that used to be「everyone」becomes a subset, grep for every reader that assumed the two were the same.Verified(SMOKE: PASS, 57 drawer checks — 35 from T4 plus a 22-check all-human battery that drives a cast-less room end to end). Live, in a room with NO persona seated(two minted humans, 13/13 over the real HTTP routes): the wheel spun 「dog / cat / elephant / lizard → elephant」; Ben opened a sealed round and Amy's answer was invisible in Ben's replay while the chips read 1/2, then both opened together on his; Amy dealt ZEBRA, PENGUIN face down and each replay carried only the public deck and that viewer's own card, never a name paired with a card; Ben showed his own hand; Amy took the cards back(hands empty on both sides); the board and clock were pinned, set and taken down by different people; and every cue turn evaporated on drain(drain_turn discards a cast-less queue)— no model was ever reached for. Paint(375px, fresh shell): eight rows with house glyphs incl. the new 🎡(/emoji/1f3a1.svg), the seal count visible without expanding, the list scrolling inside a sheet flush at 812, no horizontal overflow; a real wheel spin landed 「dog / cat / elephant / lizard → dog · You」 on the dock, and a sealed round typed through the composer rendered the held bubble(「Sealed — revealed when everyone's in」)with the card at 1/2 and the strip flipped to 「Sealed in — send again to revise」.
T5 inherits a live hook, not a TODO. room_tool_access() is called on every one of the four doors and currently answers「a free room is open to its members」; a card session tightens it by editing one function body against the card's grant list — nothing else moves. Deliberately NOT built, as the contract asked: no user mute/floor(T6 only, inside a session), no user tags anywhere, and no fifth action — the drawer is closed at four. Also parked: humans DEALING(T3's deck needs a for-list editor and a「who is playing」question the drawer has no room for); a guest's own <roll away/>-equivalent(nothing a guest can arm needs taking down — their die resolves instantly, their clock rings, their ballot closes); and attribution avatars on the clock and vote capsules(the die's record carries the roller's face since v544; the other three carry the name only, which reads fine and was not worth a fourth capsule variant this step).
<roll deck="狼人×2, 平民×3" for="all" into="safe"/> · <reveal who="u3"/> — the deck is the randomizer's third distribution(ruling ⑦)and into= is its delivery axis, so the whole feature is two knobs on the die's existing router: one branch in extract_panel_rolls, one _deal_arm inside _apply_panel_rolls, a deal branch in dice_state/tap_roll, and self.deal beside self.gate/self.clock in state.json. Events: roll_offer(deal) · deal_seen · deal_done · deal_reveal — and every one of them carries the DECK or a NAME, never a card. New: deal_mine(viewer)(the gate's mine pattern), _deal_note()(the GM's copy, the sealed-notes idiom), the deal SSE channel, the hand slot's second tenant .hand-card, and a deal state on the die dock. 34 smoketest checks, 14 i18n keys, a # SERVER DICE extension + a deal line in the worked example.
<roll>, lives on a roll_offer, and is the randomizer's payload — putting its 「已看牌 3/6」 on the ballot's card would have split one object across two surfaces. The die slot renders the deal in the collect-roll's proven chip shape. If T5/T7 generalize the checklist into its own visual, this is the row that moves.deal_seen a real event bought replay-safety, restart-safety and the room's chips for free, off the same projection the die already had, and left self.deal holding exactly one thing: the secret. That split is the shape to copy for any later mechanism with a visible-count-over-hidden-content.DEAL_PEEK starts false every time and the lifted card is a tap away, not on screen. It is also why the card never enters the stream: a bubble is scrollback, and scrollback gets scrolled past someone's shoulder.for= deals to the room. A die with no for= is the host's own draw — but a host cannot hold a card(ruling ⑤: the persona hosts, humans play), so the only sane reading of a bare <roll deck="…"/> is「deal it out」. for="any" reads the same way.<roll away/>. Clearing the table clears the dock, not the safe: self.deal survives, my hand keeps my card, and <reveal who="uN"/> still opens an entry. The table is where the game's current move sits; the safe is where a commitment sits until it is opened.dice_state() read the deal first and returned — so when a face-UP deck posted its roll_result under the standing face-down offer, the dock kept showing the old deal. Caught only live(the smoketest exercised each shape alone, which is precisely the blind spot); the fix is one clause — the deal reads first only while it is still the table's latest word — and it now has its own test. Any later mechanism that shares the roll_offer/roll_result pair must ask the same question: what happens when the NEXT thing lands under my offer?<reveal/> to take attributes([<]/?reveal…, so a stray </reveal> strips rather than rendering)made a paired <reveal who="u3"></reveal> collect twice — and the second collect had no who, so it would have opened the envelope's oldest sealed note as a side effect. Capture the slash and skip closers. _VOTE_TAG_RE has the same shape and is saved only by requiring options.deck= uses the ballot's one-separator-wins rule, and ×N is deliberately a SUFFIX only: a leading count(「2 狼人」)collides with card names that legitimately start with a number(「7 of hearts」). Live, DeepSeek wrote deck="狼人, 平民×2" unprompted — the taught form exactly.ROOM_DEAL is read by setRoomDice(), which sits 200 lines earlier; declaring it next to the hand-card code would put it in the temporal dead zone for anything that runs before the script's tail(the v477 boot kill). It is declared with ROOM_DICE, with a comment saying why.for_uids)and gets the face only from its own tap response. So a member who was watching when the deal landed needs no reload, and no card ever rides a channel more than one person reads.Smoketest 34 deal checks, SMOKE: PASS; i18n_audit clean. Live on the dev server(Mark Twain, DeepSeek, three minted humans over the real HTTP routes): 「开一局狼人杀吧…两个平民一个狼人,你来当法官,把身份牌发下来——面朝下,每人只看自己那张」→ the persona wrote the tag unprompted and correctly(deck="狼人, 平民×2" for="all" into="safe"), said「牌已经在我这里了。它不在桌子上,在你们每个人的屏幕后面」and stopped. Three taps returned 三张不同的牌,每人只拿到自己的; Ben's replay carried his own card and no other, and no turn in it paired a name with a card. The last pickup drew exactly one cue and the host moved the game on. 「Cy 出局了。把他的身份牌亮给大家看吧」→ <reveal who="u13"/>, again unprompted, and the card matched what Cy had actually been dealt, with the deal timestamp beside it. The face-up variant fired from 「明牌发,直接亮出来谁是几号」→ <roll deck="第一, 第二, 第三" for="all"/>, posted publicly at once. Paint(375px, light + dark, zh + en, fresh shell, SW purged): the face-down hand card wins the hit test at its centre(elementFromPoint → handCard), a real tap lifts it(dashed → solid, --coral-soft)and ticks the dock to 「已看牌 1/3 · 你 ✓」, tapping again puts it face down, a reload brings it back face down, and another member's tap ticks the chips live over SSE with no reload(2/3 → 全员已看牌 + the done capsule). The reveal card reads 「亮牌 · CY / 狼人 / 发牌于 13:36 · 服务器存证 / ✓ 发牌后未曾改动」. No horizontal overflow at any state.
THE DEAL HAD NO CLOSING MOVE. Owner:「when i told the host the game is over, she never take the card off the table」— and the raw showed she had obeyed(roll_clear {"by": "Tess"}). The bug was mine: I let the safe outlive <roll away/> so a late reveal would still work, which meant the table cleared for the room while every player kept a card in hand, permanently — no tag, route or gesture could end a deal. A deal now ends the way a real one does: away collects the cards, the hands empty, and the room sees「收牌」. Two more came out of the same probe, and all three are one failure shape:
for="u1,u16,tess" — its own slug — the id parser took [1,16], dealt to the humans and dropped the host without a word. It then narrated 「剩一张先发言在我手上」: a card it had never been dealt. Ruling ⑤ says a host cannot hold one; the mechanism has to SAY so where the model reads. The deal note now carries「YOU HOLD NO CARD」and the face-up cue names the cards that went to NOBODY. Live re-probe:「我不拿牌——我是发牌的」, unprompted.<reveal who="u7"/><reveal who="u8"/><roll away/> — and got zero reveal cards: _apply_panel_rolls ran before _apply_panel_notes, away emptied the safe, both reveals hit nothing, and the host fell back to naming the two roles in prose — an unbacked claim, which is the one thing the safe exists to prevent. Notes now apply BEFORE rolls: the safe opens before the table is cleared. Any two appliers a single reply can name in sequence have an order, and the SP's taught sequence is the one that must win.deal_bad + stderr). Third sighting of the v555 law in this build: T1's refused duration, T3's refused deck, and now T3's empty reveal. Write the counter when you write the extractor — every one of these was found by a human noticing prose that did not match the machine.Verified(v563, 42 deal checks, SMOKE: PASS): 「好了,这局结束,收工吧」→ <reveal who="u7"/><reveal who="u8"/><roll away/> → two proof cards(「发牌于 14:18 · 服务器存证 · ✓ 发牌后未曾改动」)then the collection, in that order; both hands empty on the wire and at paint(hand card gone, dock hidden, 「Tess 收牌 · 2 张」).
The night phase is a CARD's job, and this probe shows why. Handed a finished deal, Twain narrated the whole night by itself in one turn —「狼人,请睁眼…狼人,选好了就闭眼。天亮了…昨晚是平安夜」— playing the wolf's move for them and resolving a phase that has no barrier in it. Nothing in T3 is wrong there: the deal fired, the cue fired once, and no card leaked. What is missing is the loop(toolbox §4)— the card's EACH ROUND with a real checklist between the beats. T5 should treat「the GM plays the players' moves」as its own acceptance, not just「the opener fires」. Also parked, as the contract asked: persona-held cards(ruling ⑤)· humans dealing(T4's drawer — the applier already attributes by slug and every capsule renders a by it does not assume is a persona)· a second deal replaces the first, whose entries then become unrevealable(one deal at a time, the clock's rule; nothing has needed two yet).
Not a T step — the follow-up owed by T2's last trap, and it retired that trap's standing hypothesis. Three changes: _tool_state_signal() now returns the EMPTY_TABLE clause instead of "" (the FURNITURE IN PLAY row is always in f's brief, and says so when the table is bare); THE ROOM HAS FURNITURE in floor_producer{,.v2}.txt gained a recognition rule plus two failure modes caught alive (the verdict frame, the hush); and build_system_blocks's # THE CLOCK section gained CLOCK_ASKED_FOR — a request for a stretch of time IS the clock, grant it by setting it. 3 selftest checks changed or added.
f switched fully off: the vote cell is f's fault (75% with no f), the clock cell is the panel's (25% with no f). One number ends an argument that prompt-text alone cannot settle. Use it early.seed_from in probe.py holds room state byte-identical and moves only the ask).paired.py alternates arms in blocks inside each round; use it, not probe.py, for any before/after claim.run_room.selftest() pins f's brief exactly. Changing what f reads fails REACT — and because ok accumulates, everything after it fails too (WEBSEARCH looked broken and was not). Read the [XX] line, not the last section named.SMOKE: PASS (i18n_audit clean, no UI copy touched). Paired acceptance, 3 rounds × 10 per arm per cell, HEAD vs ship: see the table in floor-producer.html § the soft ask. Chinese is not regressed — that was the release gate, since v560's 82% is the thing worth protecting.
The soft-ask clock cell (~5% live): the panel would fire ~50% if f let it speak, so the work is in the staging ladder — a request for quiet must not be able to draw a hush or a bench. Also still open from v560: a prop resolved in prose poisons the room (T5's openers must fire on turn one).
Owner ruling(reasons: model-switch resilience + English users), applied to all six tool SP sections + the worked example in build_system_blocks: bodies were already English; every exemplar was Chinese-only and is now an English-first pair with a Chinese twin(title="Score" …the room's language: 比分; the worked example now runs in English with a compact zh mirror line). This is also the standing suspect behind T2's 37% English arm rate(all exemplars were zh)— the pair format is the fix candidate; re-run the rooms-dev/_fp_tools/probe.py English cells to measure. Law added to §0(#10)— T3+ must ship exemplars as pairs. Chinese-only exemplars would starve English rooms; English-only would regress the proven zh crutch. UI strings unchanged(English-as-key i18n already satisfies the ruling).
⚠ Measured the same day — the pair format is NOT the tool-arm fix, and no re-run is owed. The 37% figure it was aimed at does not exist: that comparison put an English 「Let's put it to a vote」 against a Chinese 「投个票」, and a 2×2 crossover over one shared seed shows a Chinese ask in an English room arming 16/20 while the English one armed 4/20. Matched for phrasing English out-performs Chinese, so exemplar language was never the carrier — the ask's phrasing is(see the entry above). The ruling stands and the pairs stay: its own reasons — model-switch resilience and English users — are untouched by this, and Law #10 is unaffected. Only the tool-arm rationale is retired.
<vote options="加辣, 不加辣, 弃权" anon/> · <vote close/> — implemented, as the contract asked, as a KIND of the circle's container: self.gate gains kind("text" = the untouched circle), and one gate_kind() is the only place that reads it. That decision paid for itself all day — arm-over-an-open-container, atomic detach, restart survival, the two-tap Release and the whole _gate_finish door came for free, and the diff is a router row(extract_panel_votes → the existing _apply_panel_gate), three Room methods(vote_tap · vote_finish · a _gate_note branch), one route(POST /vote_tap), events vote_open / vote_result, an SSE "vote" channel, the ballot branch of the dock card + the result capsule + the who-voted sheet, a # THE CIRCLE extension + a worked-example line, 32 smoketest checks and 8 i18n keys.
<gate away/> and the ballot <vote close/>. A model reaching for the neighbouring form is not making a mistake worth punishing, so away, close and end all take down whatever container is open, and arming either kind over an open one releases/fires it rather than destroying what is in it. One consequence is load-bearing and tested: a circle's typed words are never lost to a ballot armed on top of them.<vote …/><vote close/> in one reply would arm an empty ballot, fire it, and cue a turn that can do it again — forever. The board's「away applies only when the turn set nothing」rule, adopted for the same reason. Note the ballot needs no _suppress flag beyond this: a vote cannot fire without human taps, so the result cue cannot self-loop(and a runoff armed from a result cue is exactly right).q= is accepted though the grammar has none. The SP asks for the question in the persona's own words, and personas obey — every live ballot came back with q="". But the T1 rule cuts both ways: if a model writes q="要不要继续" it must not be swallowed. It rides as the card's title; without one the card reads「Vote」and the open capsule records the options themselves, so the record still says what was asked.vote_finish discards it, so「never guess at it or invite anyone to reveal themselves」is a statement of fact the host can rely on. Live, Twain used it well:「两边都不知道对方是谁」.,、,、、、| all at once, which reads as obviously generous. The first live ballot that mattered came back options="周六爬山, 周六看电影, 都行,随大家": the persona separated with the ASCII comma the SP teaches and wrote a Chinese comma inside one option. The ballot grew a phantom fourth choice. Now one separator per list, first match wins, in the order | → , → 、 → , — the marks most likely to appear inside an option are consulted last. Every later step that parses a LIST out of an attribute(T3's deck= above all)should assume its separator will appear inside an item and decide what wins.投你真心的一票,不解释,不铺垫,投完就停(it read the request as「YOU vote」, and the model cast a vote:「爬山。」); the same request that had armed a ballot minutes earlier was staged 不替任何人决定, and then — the flat statement of the problem — 接住这个请求…不照办也不拒绝,一句带过: the floor producer explicitly staged NON-COMPLIANCE with a tool request. The FP does not know the room has tools, so a request it reads as small talk gets manner-staged away, and manner outranks grammar at generation time. Two things follow. ① The persona is not the weak joint any more. Every time the FP let it through, the grammar was perfect — options, anon, separators, all unprompted. ② Arming is non-deterministic: identical phrasing armed and failed on different turns, so a single failed probe proves nothing about a tool. Re-run before you debug. T1 saw a cousin of this(cue turns staged as greetings); T5's card openers are compliance pins and will meet this head-on — the FP's brief may have to learn that tools exist before an opener can be trusted to fire. FIXED 2026-07-22, on the floor producer's own surface. A controlled replay probe (rooms-dev/_fp_tools/probe.py — same room state, the same ask replayed N times) put the real baseline at 25%, not 67%, and settled the attribution: Tess, whose character IS exact execution, failed as often as Twain, so the persona was never the weak joint. Four staging failure modes came off the logged directives — the「你来投票」misread, outright veto(「不投票」), the boundary clamp(「不替他们选」), and the spectator frame("you're the witness, not the voter" — the FP believed a vote happens without a host). The fix is deliberately NEGATIVE so the firewall and the boundary law both hold: a THE ROOM HAS FURNITURE bullet teaches the FP that the objects exist, that only a HOST can operate one, and that its single duty is never to FORECLOSE an act — it still never names a tool, because whether to reach for one stays character-gated. Plus a live FURNITURE IN PLAY row in f's brief (so 「结束投票」 is honourable at all), and an SP clause ranking the ACT above the manner. Chinese vote cells 25% → 82%; both halves are required — neither alone clears 65%. ⚠ English reached only 37%, and there it is no longer the FP: fired and missed turns carry indistinguishable staging. The standing suspect is that all 12 tool exemplars in the SP are Chinese (a trial English exemplar was a wash and was not shipped). A card opener written in English cannot yet be trusted to fire — T5 should treat that as its own step. — and that suspicion was WRONG, disproved v561 the same day. A 2×2 crossover (room language × ask language, same seed) showed the two cells were never asking the same thing: a Chinese ask in an English room armed 16/20, and "Open a vote for us" armed 18/20, while "Let's put it to a vote" armed 4/20. The exemplars were never the blocker; the phrasing was. A request that names no actor — English let's above all — is read as an act the guests already performed, and f stages a reaction to a ballot nobody opened. For T5 this is the load-bearing correction: an English card opener is NOT the risk — matched for phrasing, English arms at 90–100%, better than Chinese. What an opener must avoid is the soft, actorless wording. Full write-up: floor-producer.html § the soft ask.room_say's hold branch, the client's gated flag in send(), and renderGateMode/syncSealedPlaceholders. Miss one and a message during a vote renders as a sealed bubble that never opens. A grep for ROOM_GATE / getattr(r, "gate" is the checklist.translateX(375) — were the entrance transition never advancing(getAnimations() reports running, currentTime 0). el.getAnimations().forEach(a => a.finish()) before measuring, or you will chase two phantoms. The screenshot path still times out(T1's finding, unchanged), so paint verification stays measurement-based.Smoketest 32 vote checks, SMOKE: PASS; i18n_audit clean. Live on the dev server(Mark Twain, DeepSeek, two minted humans): asked in plain language, the persona wrote <vote options="加辣, 不加辣, 弃权"/> unprompted; Amy tapped, revised, Ben's tap fired it; the host read 「一比一…Amy不吃辣,Ben要辣的,这盘得各点一半了」—— facts read exactly, no winner invented, the tie handed back to the room. Anon: asked for「别让人看出谁投的」→ anon unprompted; after the fire the string voters appears nowhere in state.json, and neither viewer's replay carries it. Chat through a ballot: a mid-vote message posted, the panel answered it(「当然能说——」), the ballot stayed open — the differentiator, working. Close: Ben's two-tap Close(/gate_release → the vote branch of _gate_finish)fired at n=1/m=2, reason「closed early by Ben」, and the host said「Ben还没出手呢」rather than reading a unanimous room. The panel's own <vote close/> is smoketested at the applier level but was never reached live — three attempts to make a persona close its own ballot were all staged away by the FP(see the trap above); it fires through the identical _gate_finish vote branch the Close button proved. Paint(375px + dark, fresh shell, SW purged): the 📊 is house art(/emoji/1f4ca.svg), three CJK options sit on one 27px row inside the viewport with no horizontal overflow, a real tap fills the pick coral(--coral-soft on --coral)and ticks the chip while the composer strip stays off, an option wins the hit test at its centre(the v542 lesson), the result capsule lands live over SSE and collapses the dock with it, the non-anon one opens the who-voted sheet(Esc closes it — the back layer is registered)and the anon one is not tappable at all.
for= voter lists(the contract parks them until a card needs them); personas voting(ruling ⑤ — a <seal> is explicitly dropped on a ballot, and the count skips p: subs by construction); humans STARTING a vote — that is T4's user_vote, which is why the applier attributes by slug and every capsule renders a by name it does not assume is a persona.
<clock min="5" label="自由讨论"/> · <clock away/> — the router row(extract_panel_clocks → _apply_panel_clock in drain_turn's bookkeeping, a clocks PARSE_STATS counter, outside-<speak> salvage free); state self.clock in state.json; events clock_set / clock_clear / clock_ring; the dock's third slot #clockPin(order: board · circle · clock · die); rep.clock + one "clock" SSE channel carrying BOTH the dock state and the record capsule; a # THE CLOCK SP section + a clock line in the worked example; 16 smoketest checks; 5 i18n keys.
roll_cue — it carries its own clock_cue. The contract said「roll_cue-style flag so the loop guard applies」, but roll_cue's only effect is _suppress_rolls, and a ring's whole job is to hand the floor back so the host moves the game on — which usually means arming the next phase's tool.「五分钟到了,现在各自掷骰」would have silently dropped the dice. So the guard was rebuilt around the loop that actually threatens us: _suppress_clock forbids SETTING a clock in a ring-cue turn(a 1-minute clock that re-sets itself on every ring = a panel call a minute, forever)while dice, the circle and the board stay open. Both halves are smoketested.left too. The contract said「client computes from end_ts」— correct, and no server ticks were added. A phone whose clock is minutes off would still have counted to the wrong moment, so every payload also carries server-seconds-remaining and the client keeps one CLOCK_SKEW offset. Four lines; kills device-clock skew._clock_note() rides every turn(the _board_note / _gate_note idiom): 「the clock is running — m:ss left … do not narrate the time」. Not in the contract, but the SP's one hard rule needs enforcing where the model actually reads, and without it a host cannot know a clock exists.min outside 1–120, a bare <clock/>, a stray </clock>: no clock, no leaked tag in a bubble. A visible <clock min="999"/> in a persona's speech is the one failure worse than a no-op.evict_guard. The room's ring timer holds that Room object; LRU-evicting it would fire the ring on a detached instance and persist() it over the rehydrated one. Any future step that parks a live timer on a Room(T7's child-room clocks especially)must add its own predicate there.threading.Timer defaults to a NON-daemon thread. A 120-minute clock would hold the process open at shutdown. daemon = True, always.state.json, never from an event projection. The clock is live state(the self.gate family), not a board-style read-time projection: __init__ re-arms after _load_state, on a ≥1s fuse so the timer thread can't run while RoomManager.get still holds the manager lock. A missed deadline rings once on that fuse.guest text. The host recovered fine(it read the cue, not the staging), and roll cues have carried the same wart since v543 — but T5/T7, which lean on cue turns much harder, should expect it.es/esUser did not clear it(an earlier /api/stream had leaked past the reassigned binding). Paint-level verification was done by measurement instead: getBoundingClientRect, computed colours, and an elementFromPoint hit test at the pin's centre — which is the check that actually matters here (the v542 lesson: the dock must WIN the hit test, not be painted over by the stream). It returns cp-t · inDock=true.Smoketest 16/16 clock checks, SMOKE: PASS; i18n_audit clean. Live on the dev server(Mark Twain, DeepSeek): 「花 1 分钟安静想一下,用房间的计时器,标签写"安静思考"」→ the persona emitted the tag with no words at all(the FP had staged it silent — the router correctly dropped the empty bubble and kept the action); the ring fired 15 ms after the deadline, minted its event, cleared the clock and drew exactly one cue turn(「时间到了。Amy,Ben——你们想的那件事,现在可以说说了。」). Replace, away, both members' replays, and a restart-with-a-future-clock all behave. Paint: the strip counts down live(7:39 → 6:55 → 3:50), tabular numerals, coral under 30s, the ⏰ beat retires after ~6s and collapses the dock with it, 375px dark mode holds a 22-character label with no horizontal overflow, and all five strings render in zh.
The grammar had no sub-minute unit, and refusing one was silent. Within an hour of T1 landing, two different personas(Newton, Buffett)independently wrote <clock min="0.25"/> for a 15-second countdown — because a countdown is naturally seconds — and the \d+ capture read the「0」, so all three set attempts became nothing at all: no clock, no capsule, no log, no signal to the host. Newton, denied the tool it believed it had used, offered to count the seconds out loud — precisely the untrustworthy narration the clock exists to replace. Three lessons, all worth carrying into T2–T7:
sec="30" is its own attribute, a fractional min is honoured, and the SP teaches「use the unit you'd SAY」.clock_bad in parse-health plus a stderr line. Every later step's extractor should ask what its own silent rejection looks like from the persona's side.grep for <clock[^>]*> in state.json's history(the Holmes law, again).Re-probed live: 「给我们 15 秒倒计时」→ Newton wrote <clock sec="15" label="倒计时"/>(the new attribute, unprompted), the strip counted 0:15 down in coral, the ring fired 3 ms after the deadline and drew one cue(「时间到。二位请——」). Capsules read the duration as a person would say it — 15 sec · 5 min · 1:30 — and both read paths normalize a pre-v555 room's stored minutes, including a v554 replay still sitting in a browser's room cache.
tEmo() onto the same /emoji/<cp>.svg art reactions and message text already use(clock ⏱⏰ · die 🎲 · envelope ✉ · circle 🔒🔓). T2–T7: add your glyph to TOOL_EMO and call tEmo("name") — never paste the character into a template. ⚠ a swept surface is a PICTURE: innerText drops it, read img.temo[alt].label="Dan", and the SP never said what a label is FOR. The format stays(time first, then label — owner's call); the SP now asks for the phase in the room's own words, and「if the time really is one person's, say『Dan 的回合』, never just『Dan』」.Only the panel may set a clock — humans get theirs in T4(user_clock, the touch drawer), which is why the applier attributes by slug and the capsule renders a by name it does not assume is a persona. No cross-room or offline scheduling(the rail's business), no per-user clocks, no server countdown ticks — as the contract asked.