← 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 eight fixes v574 v575 presence properties v577 · T8

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.
hardened 2026-07-24A separator inside an option used to be eaten by the split. Only the persona path is exposed — a human's options arrive as a real ARRAY(one field per option)and no mark can ever be a separator — but options="…" is one string, so it must be cut somewhere. The existing defence is precedence: | , 、 ,, and the first mark that actually yields ≥2 parts wins, so an option may safely contain any mark ranking below the one delimiting the list(this is what keeps 「都行,随大家」 whole inside a comma-separated list — a real incident). What precedence cannot solve is the collision: an option whose own words carry the SAME mark the list uses(options="今天不行,明天可以, 弃权" — the ASCII comma is both). Two routes now express that intent: the taught one — delimit the LIST with | when an option contains a comma(the SP carries it with EN + zh worked pairs)— and the floor under it, an escape: 今天不行\,明天可以 splits nowhere and the backslash is stripped from the ballot. The bare ambiguous case still splits, and no parser can fix that — there is no signal to read — but intent is now always expressible.

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 · The eight-item pass(v574)

#the notewhat it was
1the keyboard retracts on the first character of option 2The list grew by re-rendering its own innerHTML — which destroys the node you are typing in, so the field blurs and the keys go. Rows are appended and popped one at a time now; every existing input keeps its identity, its caret and its focus. Same family as never-relayout-under-a-finger: never DESTROY the focused node either. Verified by node identity, not by eye — SAME_NODE and STILL_FOCUSED both hold through every growth and collapse.
2reuse the house dialogThe v573 page became the house dialog.modal-scrim / .modal, × top-right, Cancel + commit foot). A poll editor is a form with a commit, which is exactly what that dialog is for; the app should not grow a second chrome for it. Full-screen on a phone by the house rule, 560px on desktop.
3the opener's name should follow the speaker-name CSS, with colourIt now renders through the same grammar a message head uses — verified equal to a live head's name in size, weight and seat colour(rgb(126,120,181) for Tess). Note that humans have no seat colour in this apphumanColor() returns muted by design), so a human's name reads muted on the card exactly as it does on their bubbles.
4the persona still doesn't write q=House law ⑧, and I had half-applied it. v573 added prose to the vote section but left the WORKED EXAMPLE showing <vote options="yes, no"/> — titleless. The example is what the model copies, so prose alone got partial compliance. With q= in the example: 3 of 3 independent asks produced a titled poll(今晚吃什么 · 会议时间 · 要不要延长期限). If a step adds grammar, the example is not optional documentation — it is the mechanism.
5OPEN in green / CLOSED in red, top-rightA mono pill at the head's right end: --green open, --rose closed, both real theme vars so light and dark each resolve their own(#5E8B6E / #8FB59A).
6the count bar sometimes disappearsThe row is a COLUMN flex container, so the bar's height:5px was its MAIN size — and the default flex-shrink:1 was free to eat it whenever the row was tight(which is why it was intermittent: it needed a wrapping label to squeeze). flex:0 0 5px.
7the poll bubble is a bubble — match the bubble's typeOption text is now 15px × the Text-size dial, the same calc(15px * var(--read-scale)) a message bubble uses; the head sits at the message head's own 12.5px and the question at 16px. Everything on the card scales with the reader's dial rather than being frozen at a design-time number.
8Create poll should be a buttonThe drawer row is a direct action — one tap opens the dialog, nothing expands. It keeps the accordion only while a poll is open, where the body holds the cancel.

10 · v575 — two flex traps and a measurement I got wrong

measurementMy v574「3 of 3」was measured with floor:"off", which is not the setting anyone actually runs. Re-measured with the FLOOR ON: the persona titles every poll it opens, but the first ask often opens no poll at all — the known floor-producer tool suppression, not a q= problem. The tess-test-rig note says to isolate with floor:"off" before blaming a profile; it does not say to report that number as the result. Isolate to diagnose, then re-measure in the real configuration before claiming a fix.

11 · v576 — two rules that left the poll and became the app's

12 · The presence properties(v577 · T8 pilot, owner combo pass v578)

The poll was the owner's hand-polished reference tool, so it is where the §9 presence properties got built first: blocking · place · unique, with the WhatsApp defaults none · inline · no. Two of those three changed the poll's behaviour — a default poll used to re-float above the composer and be the only one allowed open; it is now a bubble that scrolls away and coexists with others. A same-day owner review then drew the allowed-combo matrix, which renamed sticky, put all in scope, and made two combos auto-coerce:

dialvalues(default first)what it does to the poll
blockingnone · host · allhost:the panel goes silent while the poll is open and reads the whole round in ONE cue at close · all:the composer LOCKS for everyone until the poll closes(the takeover meeting)
placeinline · pinnedwhere the card lives — a bubble in the stream that scrolls away · or a strip chip whose card opens in the panel.(sticky/follow retired — owner:「pin covers it, follow confuses」; follow survives only as the text circle's legacy value)
uniqueno · yesyes:opening the next poll closes THIS one first(close-with-release, never destroy)

The combo law(owner matrix 2026-07-23): placement must match blocking. Two coercions fall out, applied in _clean_props and mirrored live in the Advanced form:

Two doors, one behaviour. The human sets the dials behind an 「Advanced」fold(segmented controls, the coerced option shown-but-disabled); the persona names them on the tag(<vote … blocking="host" place="pinned"/>, also blocking="all")— unique needs no attribute, because close-first is automatic. Built human-first, then the grammar(ruling ⑨).

the load-bearing decisionunique's trigger is the open INCUMBENT's flag, never the new poll's. A live probe caught the first cut: a casual unique=yes poll wiped out a coexisting pinned one. The ruling's words are exact — 「creating an instrument whose kind has an open unique=yes instance closes-with-release the old one first」 — so a new unique poll marks itself for the next open and never clears the room. The reveal gate, the read-path law and the closed-capsule builder all carried over per-instance untouched: anti-anchoring holds in every mode(the pin chip shows n/m voted, never the split).

amended 2026-07-24 · inline + blocking=all is open to peopleThe form used to force pinned for both blocking values; it now forces it for host only. That extra coercion was never the law — _clean_props only ever did host⟹pinned + all⟹unique — so the server always accepted inline + all; only the human dialog refused it. The reasoning that justifies pinning does not survive all: pinning exists because an inline card gets lost when chat keeps flowing, and under all nothing flows — the composer is locked, so an inline poll holds the tail by construction and needs no pin to stay findable. For a run of polls it is strictly better: each poll closes, the host's reveal lands under it, the next poll opens below that, and the whole quiz reads as one clean thread instead of a sheet floating over the already-closed ones.
the evidenceThe shape was found in the wild, not designed: in the Holmes Trivia room a persona ran 5 straight polls, all five blocking=all · place=inline · unique=yes — and the human had to coach it there(「记得设置blocking:panel」)because their own dialog could not produce the combo. A user routing around the UI through a persona is the clearest possible signal the coercion was wrong.
the closed poll stays putAn inline poll's locked result replaces its bubble IN PLACE at the spawn spot(owner point)— live via a replaceWith on the vote SSE(the gate-close takes a light path so the node survives), and on replay via the vote_open node that the vote_result turn fills. It no longer jumps to the tail. A pinned poll(no stream card)lands its result as a capsule at the close position. And a new inline poll scrolls into view AFTER its content lands(the v576 zero-height lesson); while an AI-blocked poll is open a human's message posts but raises NO thinking egg(the panel is silent until close).

13 · 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 titlev574 — 3/3 once the WORKED EXAMPLE carried q=
the editor as the house dialog · focus-safe growing list · OPEN/CLOSED · bubble typeshipped v574
desktop composer-focus flashopen — not reproducible under instrumentation
a poll that closes on a timernot asked for — T1's clock is separate furniture
presence properties(blocking · place · unique)— the store becomes a LIST, polls coexistshipped v577 · T8
the Advanced fold(segmented dials)· the persona grammar(blocking/place attrs)shipped v577 · T8
owner combo pass — place(drop sticky/follow)· blocking=all composer lock · coercions(host→pinned, all→unique)· inline poll closes in place · no egg while AI-blocked · pinned auto-expandshipped v578
inline + blocking=all opened to people(the form's undocumented all⟹pinned dropped; host⟹pinned stays)· the pinned sheet fits its card(the real gap above the composer, not a flat 46vh)· option parsing hardened(escapable separators + the SP teaches | for comma-bearing options)shipped v592

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.