Dialogue · The toolbox · The poll
← The orthogonal toolbox — the architecture

The poll

T2 shipped the ballot as a sealed container: you could see who had voted, never what anyone chose, and no count existed anywhere until it fired. That was right about the danger and wrong about the experience — the card told you almost nothing. This page is the redesign(owner brief 2026-07-22, from a WhatsApp reference), its one new rule, and what shipped. shipped v570 · 2026-07-22 five edits v571 review pass v572 the editor page v573

The rule the whole redesign rests on — THE REVEAL GATE: the standings are withheld from anyone who has not voted, and handed over the moment they do. You cannot be anchored by a tally you were never sent; once you have committed, seeing it costs nothing. It is the answer to a question that looked like a trade-off(legibility or no-anchoring)and was not one.

1 · Why the sealed card was not enough

T2's ballot was a roster strip: the question, the option pills, a row of faces with ✓ or blank, and n/m. Correct, and nearly mute. It never said whose poll this was, it showed the same nothing before and after you voted, and the only place a result ever appeared was a one-line capsule after the fire. The owner's brief, drawn as a mockup:

(avatar) Newton started a Poll
------------------------------------------
Next vacation location:
Santorini (avatar of Dan) (avatar of Bob) +
*****
Lake Como (avatar of Mary)
*****************

waiting for (avatar of Tom) (avatar of Cy)+
------------------------------------------

Three separate asks live in that sketch, and only the third collided with anything: attribution(whose move this is), a waiting-for row(who is still out), and voters' faces on the option they picked — which is a live tally, and a live tally is what T2 was built to prevent. The reveal gate is what let all three ship without retiring the ruling.

2 · The card

BEFORE YOU VOTE Ben started a poll Next vacation location SELECT ONE OR MORE Santorini Lake Como Reykjavik Results are hidden until you vote waiting for AFTER YOU VOTE Ben started a poll Next vacation location SELECT ONE OR MORE Santorini 2 Lake Como 1 Reykjavik 1 waiting for you vote
The same card, one commit apart. 「—」is not「0」: a zero is an answer, a dash is a refusal to answer — and the dash is what the server actually sent. The round boxes are a single-choice poll; a multi-choice one wears squares and grows a Vote button, because a set is committed, not tapped.

3 · The reveal gate, precisely

The whole rule is one branch in gate_payload(viewer_uid):

if mine:                          # …and only now, the standings
    out["counts"] = counts
    if not g.get("anon"):
        out["by_opt"] = by_opt

Three consequences worth stating out loud, because each one is a way this could have been built wrong:

4 · Multi-choice

<vote options="Fri, Sat, Sun" multi/>(the room's language: 「周五, 周六, 周日」)· a pick more than one toggle in the T4 drawer beside 匿名(the parity law: whatever a persona can arm, a guest can arm).

singlemulti
the controlround box; the tap IS the submission(instant, D3's beat kept)square box; ticks are LOCAL until a Vote button commits
storedsubs[uid] = {ii: [i, …]} — one shape. A single-choice tap is the one-element case, and the route takes a set either way.
the countssum to the voterssum PAST the voters — 4 picks from 2 people is correct, and the cue says so to the panel in as many words
n of mcounts heads, never picks — otherwise「n of m voted」prints a number bigger than the room

Uncommitted ticks are not state: they live in one client-side variable, they are dropped by a reload or a room switch, and while you are wandering over the options the counts stay dashes. Nothing is revealed by considering a vote.

the hole, left openReveal-on-vote plus multi-select means anyone can tick every option to unlock the standings. WhatsApp has the same hole. It stays open — and v571's lock narrows it: you get exactly one look, bought with a vote you can never take back. The counter is social, not technical — your picks sit on every bar, so voting-for-everything is a public act of not voting. Every mechanical fix(cap the selections, require ≤ half)punishes the honest case, and 「I'd be happy with Thai or Indian」is the entire point of multi-select.

5 · The calls, and the one thing deliberately absent

fixed en routeA bare flag was being read INSIDE options=. <vote options="anon, named"/> armed an anonymous ballot — a poll about anonymity became one. Invisible as bugs go, since it only fires when a room votes about the very word. multi would have inherited the same hole(「multiple dates, one date」), which is how it surfaced. Both flags now read through _bare_flag(): an explicit value wins wherever it sits(anon="false" is false, not merely present), and the bare word counts only outside the quoted values.

6 · The five edits(v571)

Owner review of v570, same day. Two of them turned out to be one rule; one was a bug I had shipped.

#the editwhat it turned out to be
1 · 3a choice cannot be changed once made — single and multiOne rule: a vote is final. It also closes the hole v570 deliberately left open(revise-after-reveal), so the anchoring story is now complete rather than nearly complete. Enforced in vote_tap, not in the UI.
2「don't squeeze the bubble when the user clicks on the poll」A bug, and mine. lt-pop scaled the whole live card to .985 on every gate event — which is every time anybody taps — so your own vote made the thing under your finger flinch. The numbers moving IS the feedback(D4). The pop now survives only where the user asked to be shown the card(a chip jump).
4longer option text, wrapping40 → 120 chars, rows wrap instead of ellipsing, and the two regex caps that would have clipped a long list first went up with it(the tag's attribute run, the options= value). The row grows downward; the box, count and faces hold their edges.
5「the bubble IS the result, just locked」A poll used to vanish at the fire and leave a one-line capsule — the thing you were watching disappeared exactly when it got interesting. It now closes in place: same rows, real counts, the voters' faces where they were, the coral edge relaxed to a plain one, and a 🔒 line. ⚠ It renders from the vote_result event through the same row builder as the live card — the live card is presentation and dies with the container; this is the durable record that has to survive a reload and a replay. Identical shape is what makes it read as「the same bubble, locked」.

7 · The review pass(v572)

Owner review of v571. Two of the six were the same bug wearing two faces, and one is not fixed — said so below rather than guessed at.

#the notewhat it was
1no need for the「X opened a vote」pillRight — the card announces itself in the stream, wears its opener's face and stays there as the locked result, so a pill one line above it was an announcement of an announcement. The vote_open event is untouched(it is still the server's record of when the ballot was armed); only the render is gone.
2「don't be so busy」between the optionsRow gap 9 → 16px, and the card's own rows got air with it.
3 · 4the opener's face is sometimes the 📊 emoji until you refresh · a persona's glyph colour is wrong · multi shows circle ticksOne bug, and it is a good lesson. 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. So the closed card fell back to 📊 and to circle ticks, and a reload fixed it, because replay reads the EVENT, which had the fields all along. The live path and the replay path rendered the same card from two different dicts, and only one of them was complete. Separately: a persona's face is now drawn the way a message head draws it — the seat colour as BACKGROUND under a white monogram — verified pixel-identical against a real head. A coloured glyph on nothing was the odd one out in the app, and beside a human's photo it simply read as a bug.
6a persona cannot title a poll; a human canThe SP asks the persona to put the question in its own words in the same reply, so a q= says it twice — once in voice, once as a label the character never wrote. Now stripped and ignored(the tag still matches, so nothing leaks into a bubble). A guest keeps their title: they have no reply to ask it in. Verified through the live model path — asked explicitly for a title, the persona's poll came back with none.
5desktop: the screen flashes once when the composer is focusednot fixed It does not reproduce under instrumentation: focusing the composer at 1200×860 produced zero attribute mutations on <html>/<body>, no --app-h write, and no change in scroll, composer height or shell height across a full second. The keyboard subsystem is innocent here — preShrink returns immediately off iOS and write() takes the clearShell() branch on a mouse-only desktop. The remaining suspect is the frosted glass: .composer carries backdrop-filter: blur(24px) and .inputrow:focus-within transitions its border colour — a paint inside a backdrop-filtered element re-rasterises the whole blurred layer, which on some compositors is a one-frame flash of everything behind it. The browser pane does not composite frames, so no probe here can see a flash; a fix shipped blind would be a claim I cannot back. The discriminating test is in the findings log.

8 · The editor page(v573)

my mistake「A persona cannot add a title to the poll, humans can」was a BUG REPORT, and I read it as a ruling. The q= attribute had been accepted from the panel since v559 — what never existed was any instruction to write one, so the model simply never did. v571 then removed the acceptance, turning a missing lesson into a policy and making the reported problem permanent. Reverted in v573, and the SP now asks for the title outright(<vote q="the question" options="…"/>, marked not optional, English base + a zh twin per the language law). Confirmed on the live model path: the next persona poll came back titled 「茶还是咖啡」. The tell I should have caught: 「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.

The rest of the pass moved the guest's editor out of the tool drawer. A poll has a question, a list that grows, an order, and two switches — more than a row in a sheet can hold — so 「Create poll」(renamed from 「Start a vote」)now opens a page, sliding in from the right on the .fwd-page rails with ‹ back as its dismissal(the PAGE type). Two habits copied from WhatsApp's, both load-bearing:

ONE draft object is the truth; the DOM is a view of it. A reorder is an array splice followed by a repaint — not a pile of node moves that then disagree with what gets posted. Leaving with anything typed asks first(「Leave poll? Your edits won't be saved.」), because the page holds work nothing else will remember. Anonymous joins Allow multiple answers, and a Who votes row defaults to everyone — an untouched picker sends nothing at all, and the server reads that as the whole room, so the common case costs zero taps.

Also: the count bar's fill keeps the coral hue and loses the shout(48%, and 72% for the option you picked). At full strength a column of bars read as a warning rather than a count.

9 · Status

piecestate
attribution header(poll + sealed round)shipped v570
voters' faces on the option, with barsshipped v570
「waiting for」rowshipped v570
the reveal gate(server-side withholding)shipped v570
multi-choice(tag · drawer · set storage · the commit)shipped v570
the closed poll as a locked full card(replaces the one-liner)shipped v571
a vote is final · options wrap · no press-squeezeshipped v571
no open-pill · air between options · the opener's real face, live · no persona titleshipped v572
the Create-poll page(growing list · reorder · anon · who-votes · leave-guard)shipped v573
the persona writes its poll's titleshipped v573 — the SP now teaches it
desktop composer-focus flashopen — not reproducible under instrumentation
a poll that closes on a timernot asked for — T1's clock is separate furniture

Verified(two minted users + Tess on :8011, then at paint level on 375×812): a non-voter's card shows「—」on every option with empty bars and no faces while another member has already voted — and the wire proves it, not the CSS; the tapper's reveal arrives in their own tap response; a multi commit lands a set and the counts then sum past the people; an anon poll reveals numbers and zero faces; a reload restores opener, ticks, counts, bars, faces and the waiting row; a cached v569 body still votes. v571 adds: a change refused server-side while the same set stays an idempotent no-op; the live card's transform measured none throughout a tap and its SSE round trip(no flinch); an 86-character option wrapping to three lines instead of ellipsing; and a closed poll rendering as an inert full card — rows as <div>, counts, bars, faces, lock line, still tappable for the who-voted sheet — with zero one-line capsules left anywhere. python lib/smoketest.pySMOKE: PASS(666 checks incl. 14 new); i18n_audit clean.

The poll · owner brief 2026-07-22(WhatsApp reference screenshots)· shipped v570, same day · sits on the toolbox's T2 container and T-UI's in-stream live card · rendering conventions in the style guide.