Projector reveal, in-app lobby kick, calm autopolls, and honest vote-close copy - #3
Conversation
…vote-close copy. - Projector: grapheme-safe initials (the app got this in #48; the board was missed), and the victory splash now fades after ~7s so the unmasked wall gets the screen. Splash only restarts on the transition into ENDED. - App: moderator can remove a lobby player by tapping their tile (kick_player was chat-only); danger-styled confirm, blood-red hover, keyboard accessible. - App: background autopolls no longer set busy (no dimming, no disabled buttons, no swallowed taps) and apply results through a version-guarded setProjection — unchanged state skips the DOM rebuild, and a stale poll can never roll back a newer projection. - App: closing a tied/empty vote warns the moderator in the confirm sheet (the server's elicitation guard can't reach the app's tool-call path); both copies now distinguish all-abstain from no votes at all. - App: lobby spectator status no longer claims the lobby is full when a seat has opened; filtering 80 names with no match says so instead of going blank. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e097ac50b4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function initials(name) { | ||
| // Iterate by code point, not UTF-16 unit, so an emoji-first name shows a | ||
| // whole glyph on the projector instead of a broken surrogate half. | ||
| const first = (s) => [...s][0] ?? ''; |
There was a problem hiding this comment.
Segment emoji initials by grapheme
For names beginning with a multi-code-point emoji, such as 👍🏽 Sam or 👨👩👧👦 Lee, [...s][0] returns only the first Unicode code point, stripping the skin-tone modifier or ZWJ sequence on the projector. Use grapheme-cluster segmentation (for example, Intl.Segmenter) so the complete first displayed character is preserved.
Useful? React with 👍 / 👎.
| if (next.stateVersion === prev.stateVersion && prev.phase === next.phase) { | ||
| proj = next; | ||
| if (next.player_token) playerToken = next.player_token; | ||
| return; |
There was a problem hiding this comment.
Re-render when the projection viewer changes
stateVersion represents room storage, not the viewer for whom the projection was generated, so two projections at the same version can have different you, role-private blocks, and moderator controls. If the iframe receives a public or different-seat projection and then a private projection for the same unchanged room, this branch updates proj without updating the DOM, potentially leaving private information from the prior viewer visible or hiding the current viewer's controls indefinitely; include viewer identity in the no-op condition or render whenever viewer-specific content changes.
Useful? React with 👍 / 👎.
| ? `${(p.vote?.votesCast ?? 0) > 0 ? 'Every vote so far is an abstain' : 'No votes have been cast'} — closing now banishes no one. This cannot be undone.` | ||
| : tie | ||
| ? `The vote is tied (${tally[0]!.targetName} and ${tally[1]!.targetName}) — closing now banishes no one. This cannot be undone.` |
There was a problem hiding this comment.
Revalidate the tally before confirming vote closure
If players change their votes after this sheet is rendered, its promised outcome becomes stale while voting remains open and autopolling is deliberately paused for an open sheet. For example, a tie can be broken before the moderator confirms, after which advance_phase resolves the current server tally and irreversibly banishes someone even though the confirmation explicitly said that nobody would be banished; refresh and recompute the warning before the final action, or bind server-side confirmation to the displayed state version.
Useful? React with 👍 / 👎.
Adversarial review — findings that survived refutationI reviewed the diff against the reducer, store, view/redaction, elicitation, and board/app render paths, attacked each change from UX, play-through-flow, race, state-machine, and a11y angles, and dropped everything I could refute. 1. Tied/empty vote close from the app can double-advance the phase (latent, pre-existing — but this PR makes it the sanctioned path)
Play-through on a host that ignores (rather than fast-rejects) elicitation: moderator taps Close the vote on a tie → server parks in the elicit wait → app times out at 15s and toasts "The village didn't answer" → moderator retries → both calls eventually resolve This is pre-existing (the PR only reworded the message), and the observed ChatGPT behavior is fast-reject, so it may never fire today. But since this PR makes the in-app sheet the intended way to close a tied vote, I'd give this 2. The close-vote warning is a snapshot that can be wrong by confirm time
Inherent confirm-dialog TOCTOU, so low severity — but cheap to mitigate: recompute the detail inside 3. The "blood, not lantern" kick affordance never reaches keyboard or touch users
.tile.kickable:focus-visible { border-color: rgba(224, 82, 82, 0.5); background: var(--panel-2); }( 4. Filter matching only dead players shows an empty grid with no explanationThe new 5. Copy nits
Attacked and refuted (so you don't have to re-check)
Findings 1–2 are judgment calls on a latent path; 3–4 are one-liners. Nothing here blocks the merge. |
* Follow-up: live close-vote warning, grapheme initials, filter/focus polish. Addresses the leftover review on #3: - Close-vote sheet stays live: autopoll continues, the warning is recomputed from the current projection, and confirm refreshes then compares against the copy the moderator actually agreed to. A tally flip no longer closes a surprise banishment. 3+-way ties name every leader (app + chat path). - Skip-render now also requires the same viewer id, so a public projection at the same version cannot leave another seat's secrets on screen. - Initials use Intl.Segmenter (code-point fallback) on the app and the projector, so skin-tone and ZWJ emoji names stay intact. - Filter: only-fallen matches say so and auto-open the fallen list. - Kick/target tiles get the same affordance on :focus-visible as hover. - Live sheet refreshes restore the focused button instead of yanking focus back to Confirm. Co-authored-by: Cursor <cursoragent@cursor.com> * Close-vote confirm: abort on cancel, require a fresh tally, name the leader. Codex on #4: - Cancel / Escape / veil-click during the confirm-time refresh no longer lets the in-flight continuation advance the phase. - A failed or timed-out get_state keeps the vote open instead of closing against a stale projection. - Outcome comparison is keyed (empty / abstain / tie ids / banish id), so Alice leading → Bob leading is treated as a change. The sheet now names who would be banished. - A dead-only search opens the fallen list even if the player had collapsed it. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: William Lane <will@Williams-Mac-mini.local> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
A critical UX pass over the app iframe and projector board. Every change is a fix for something a real player or moderator would hit; no tool schemas were touched (they are frozen per DECISIONS.md).
initials()on the board sliced UTF-16 units; an emoji-first name showed a surrogate half on the big screen. The app got the grapheme-safe version in critique round 1 (DECISIONS #48) — the board copy of the same function was missed.kick_playerwas reachable only through chat, leaving the moderator with no in-app room management at all. Lobby-only (tiles are inert there otherwise), never their own tile, blood-red hover so it can't be mistaken for targeting, danger confirm sheet, keyboard accessible.busy(silently swallowing any tap that raced them) and force a full DOM rebuild twice every 10s. They now run on a separate non-blocking path, andsetProjectionis version-guarded: an unchanged room skips the rebuild entirely, and a stale poll result can never roll the board back over a newer projection (store versions are monotonic per room).Test plan
npm run typecheck(server + app tsconfigs)npm test— 102 passed, 9 skipped (emulator-gated), incl. the 2k-game fuzz sweepMade with Cursor