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
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.
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:
/gate response reads …"need":[7,8],"in":[7],"mine_ii":[] and stops. Amy's vote is in in(WHO)and nowhere else(never WHAT).gate SSE is viewer-LESS, so it can only ever carry the un-revealed shape. One payload fans out to a whole room and only some of them have earned the numbers. Every publish site calls gate_payload() with no argument, which makes the broadcast safe by construction. The tapper's own standings ride their tap response; every other voter re-reads GET /api/rooms/{id}/gate when a broadcast lands. This is the same house pattern T3 uses for a dealt card(「MY card never rides a broadcast at all」).by_opt is omitted entirely, so an anonymous poll shows bars and numbers to a voter and zero faces to anyone, ever.<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).
| single | multi | |
|---|---|---|
| the control | round box; the tap IS the submission(instant, D3's beat kept) | square box; ticks are LOCAL until a Vote button commits |
| stored | subs[uid] = {ii: [i, …]} — one shape. A single-choice tap is the one-element case, and the route takes a set either way. | |
| the counts | sum to the voters | sum PAST the voters — 4 picks from 2 people is correct, and the cue says so to the panel in as many words |
n of m | counts 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.
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 done so.{options: […]} and v569's {option: i}), because a service-worker-cached shell keeps voting through a deploy.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.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.Owner review of v570, same day. Two of them turned out to be one rule; one was a bug I had shipped.
| # | the edit | what it turned out to be |
|---|---|---|
| 1 · 3 | a choice cannot be changed once made — single and multi | One 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). |
| 4 | longer option text, wrapping | 40 → 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」. |
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 note | what it was |
|---|---|---|
| 1 | no need for the「X opened a vote」pill | Right — 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 options | Row gap 9 → 16px, and the card's own rows got air with it. |
| 3 · 4 | the opener's face is sometimes the 📊 emoji until you refresh · a persona's glyph colour is wrong · multi shows circle ticks | One 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. |
| 6 | a persona cannot title a poll; a human can | The 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. |
| 5 | desktop: the screen flashes once when the composer is focused | not 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. |
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:
touch-action:none sits on the grip alone, so dragging a caret inside a field never moves the option.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.
| # | the note | what it was |
|---|---|---|
| 1 | the keyboard retracts on the first character of option 2 | The 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. |
| 2 | reuse the house dialog | The 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. |
| 3 | the opener's name should follow the speaker-name CSS, with colour | It 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 app(humanColor() returns muted by design), so a human's name reads muted on the card exactly as it does on their bubbles. |
| 4 | the 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. |
| 5 | OPEN in green / CLOSED in red, top-right | A 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). |
| 6 | the count bar sometimes disappears | The 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. |
| 7 | the poll bubble is a bubble — match the bubble's type | Option 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. |
| 8 | Create poll should be a button | The 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. |
.vo.on .vo-box, so it replayed on every freshly-built node, and this card re-renders constantly. It now needs .fresh, stamped for exactly the one render that commits your vote. An animation that fires on every paint is not feedback, it is a flicker..vo is a <button>, and Safari's UA stylesheet centres a button's flex children — so the bar, whose only content is a zero-width fill, shrank to zero WIDTH. The closed card's rows are <div>, which is exactly why it appeared the moment the poll closed. align-items:stretch + width:100%, both explicit. Two bugs in two weeks from the same family: a fixed size on a flex item is only safe if you say which axis you meant.<vote options="…">the question</vote>). A model taught「the title is not optional」reaches for the shape it already knows from <gate>, and its words were falling out of the tag and floating loose in the bubble. Same law as v539's outside-tags salvage: take the model's meaning in whichever shape it arrives.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.loading="lazy" on the avatar photo: an innerHTML rebuild destroys the <img>, and a lazy one is not repainted in the same frame even from cache, so it popped out and back on every repaint of anything containing an avatar. Now loading="eager" decoding="sync" on avatars and decoding="sync" on house glyphs, app-wide; lazy survives only in the emoji picker's grid. Recorded in PRESENTATION.md and the style guide. The structural half: the two-tap arm rebuilt the entire card to change one button's label — it now writes the label, and the card's nodes are provably untouched(SAME_CARD, SAME_AVATAR_NODE, SAME_IMG_NODE).liveHost() scrolled at CREATION, when the host was still empty — a scroll measuring a zero-height node moves nothing, which is exactly why desktop never followed the new poll while mobile did(a short viewport was already pinned). The scroll moved to the content and became notifyAppend(), the same decision a bubble makes. Verified on 1280×860: at the bottom the card lands fully visible above the composer; scrolled up it stays put and raises 「↓ New messages」, and the nudge brings you to it.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:
| dial | values(default first) | what it does to the poll |
|---|---|---|
| blocking | none · host · all | host: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) |
| place | inline · pinned | where 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) |
| unique | no · yes | yes: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:
blocking=host ⟹ place=pinned — an inline poll gets LOST the moment chat keeps flowing, so a host-blocking poll pins itself(「AI-ONLY + inline shouldn't exist」).blocking=all ⟹ unique=yes — the composer is locked, so a second poll cannot be opened; non-unique is moot(「cannot happen」).blocking=all does NOT touch placement(amended 2026-07-24)— under a locked composer nothing can push an inline poll away, so it stays reachable without a pin. inline + all is the shape a run of polls wants; see the amendment below.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 ⑨).
unique'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).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.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.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).| piece | state |
|---|---|
| attribution header(poll + sealed round) | shipped v570 |
| voters' faces on the option, with bars | shipped v570 |
| 「waiting for」row | shipped 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-squeeze | shipped v571 |
| no open-pill · air between options · the opener's real face, live · no persona title | shipped v572 |
| the Create-poll page(growing list · reorder · anon · who-votes · leave-guard) | shipped v573 |
| the persona writes its poll's title | v574 — 3/3 once the WORKED EXAMPLE carried q= |
| the editor as the house dialog · focus-safe growing list · OPEN/CLOSED · bubble type | shipped v574 |
| desktop composer-focus flash | open — not reproducible under instrumentation |
| a poll that closes on a timer | not asked for — T1's clock is separate furniture |
| presence properties(blocking · place · unique)— the store becomes a LIST, polls coexist | shipped 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-expand | shipped 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.py → SMOKE: PASS(666 checks incl. 14 new); i18n_audit clean.