Skip to content

Projector reveal, in-app lobby kick, calm autopolls, and honest vote-close copy - #3

Merged
willtech3 merged 1 commit into
mainfrom
polish/board-reveal-lobby-kick-poll
Aug 16, 2026
Merged

Projector reveal, in-app lobby kick, calm autopolls, and honest vote-close copy#3
willtech3 merged 1 commit into
mainfrom
polish/board-reveal-lobby-kick-poll

Conversation

@willtech3

Copy link
Copy Markdown
Owner

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).

  • Projector: emoji names no longer render as broken glyphs. 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.
  • Projector: the victory splash now fades out (~7s) to the unmasked wall. Previously the full-screen splash never dismissed, so the projector could never show the role reveal — the part the whole room leans in for. The splash only (re)starts on the transition into ENDED, so resizes and late lobby joins don't replay it.
  • App: the moderator can remove a lobby player by tapping their tile. kick_player was 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.
  • App: background autopolls no longer lock or rebuild the UI. Polls used to set 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, and setProjection is 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).
  • App: closing a tied or empty vote warns the moderator. The server elicits a confirmation for this on the chat path, but the app's tool-call path can't receive elicitations and sailed straight through. The confirm sheet now mirrors the guard — and both copies distinguish "everyone abstained" from "no votes have been cast" (they said the latter for the former).
  • App copy/polish: a lobby spectator is no longer told "the lobby is full" after a seat has opened (post-kick), and filtering 80 names with no match shows "No villager matches" instead of a silently empty grid.

Test plan

  • npm run typecheck (server + app tsconfigs)
  • npm test — 102 passed, 9 skipped (emulator-gated), incl. the 2k-game fuzz sweep
  • Independent adversarial review of the diff (one finding — the all-abstain copy — fixed)
  • Projector at ENDED: splash plays, fades, unmasked wall visible; reset re-arms it
  • Lobby as moderator: tap a player → confirm → removed; own tile inert
  • Autopoll on with an idle room: no button flicker, focus/selection stable
  • DAY_VOTE with a tie / all abstains: close-vote sheet shows the warning

Made with Cursor

…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/server/board.ts
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] ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/app/main.ts
Comment on lines +141 to +144
if (next.stateVersion === prev.stateVersion && prev.phase === next.phase) {
proj = next;
if (next.player_token) playerToken = next.player_token;
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/app/main.ts
Comment on lines +404 to +406
? `${(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.`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@willtech3

Copy link
Copy Markdown
Owner Author

Adversarial review — findings that survived refutation

I 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. npm test passes locally (102 passed, 9 emulator-gated skips). What follows is only what held up.

1. Tied/empty vote close from the app can double-advance the phase (latent, pre-existing — but this PR makes it the sanctioned path)

advance_phase's tie/no-votes guard calls elicitForm with the default 90s timeout (src/server/tools.ts, the elicitForm call in the DAY_VOTE branch), while the app's callServerTool wrapper gives up at 15s (CALL_TIMEOUT_MS, src/app/main.ts). The night-action picker already sets timeoutMs: 20_000 with a comment naming exactly this hazard ("if a client neither supports nor cleanly rejects elicitation… rather than stalling past the host's timeout on stage") — the vote-close guard didn't get the same treatment.

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 unavailable and advance: the first does DAY_VOTE→DUSK, the retry does DUSK→NIGHT, silently skipping the dusk narration beat (the banishment reveal) on stage. The sheet's phaseAt guard can't catch this — the phase is still DAY_VOTE when the retry is confirmed.

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 elicitForm the same short timeout as the night picker — it's a one-line, schema-free change that removes the failure mode entirely.

2. The close-vote warning is a snapshot that can be wrong by confirm time

moderatorAction computes the tie/abstain detail from the projection at render time, but the sheet freezes it (sheet = { …detail… }), and autopolls are paused while a sheet is open. Votes can still change until close (players can revote from their own devices), so a moderator who leaves the sheet open while the room argues can confirm against stale copy: "the vote is tied — banishes no one" when a late vote change means someone will be banished, or the reverse. The server's elicitation path re-checks at call time; the app mirror can't.

Inherent confirm-dialog TOCTOU, so low severity — but cheap to mitigate: recompute the detail inside renderSheet() from the current proj instead of freezing it, and/or keep polls running while this particular sheet is open (renderSheet already survives render(); it's only the frozen detail string that goes stale).

3. The "blood, not lantern" kick affordance never reaches keyboard or touch users

.tile.kickable styles hover only (src/app/styles.css). Keyboard users get the UA default focus ring — same ring as a targeting tile, no red — so the one visual cue the PR's own rationale relies on ("can't be mistaken for targeting") is absent exactly where misclick risk is highest. Touch users get no pre-tap cue at all. The danger confirm sheet is a solid backstop and the modbar note helps, but a one-liner closes the gap:

.tile.kickable:focus-visible { border-color: rgba(224, 82, 82, 0.5); background: var(--panel-2); }

(.tile.tappable has the same hover-only gap, pre-existing — worth fixing in the same breath with the lantern color.)

4. Filter matching only dead players shows an empty grid with no explanation

The new nomatch message requires both aliveShown and deadShown to be empty. If the query matches only fallen players, the alive grid renders empty with no message, and the actual matches can be hidden inside a collapsed "The fallen" details. A moderator searching for a name mid-game (e.g. to verify someone died) sees the exact silent-empty-grid this change set out to kill. Suggest: show a variant message when aliveShown is empty ("no living villager matches — check the fallen"), or auto-open the fallen section when it contains the only matches.

5. Copy nits

  • A 3+-way tie reads "The vote is tied (A and B)" — omits the other tied leaders. Same shape as the chat-path message, so at most an alignment for later.
  • initials() is code-point-safe but not grapheme-safe: ZWJ sequences truncate to their first component (👩‍🚀 → 👩) and a lone regional indicator from a flag-first single-word name can render as a letterbox. Strictly better than surrogate halves, and app/board are now consistent — noting it only because DECISIONS #48 calls the app version "grapheme-safe," which slightly oversells it.

Attacked and refuted (so you don't have to re-check)

  • Version guard vs. reset/kick: stateVersion is genuinely monotonic per room. Memory store uses a global logical clock; Firestore takes max updateTime and every accepted mutation writes ≥1 doc (kick and reset are core-doc writes; the action-doc delete branch is unreachable in practice since player ops always set their entry). A stale poll can't freeze the board after a reset.
  • Same-version render skip: any observable state change bumps the version (action docs are included in the version exactly in the phases they're readable), so skipping the rebuild on equal versions can't hide changes.
  • Splash replay: splashShown correctly survives SSE reconnects and resizes; ENDED always has a winner (endGame guarantees it); reset re-arms it. Page reloads mid-ENDED replay once by design.
  • Tie/abstain copy vs. reducer: app tie predicate (tally[0].count === tally[1].count on the desc-sorted, abstain-excluded, alive-target-only tally) matches resolveVotes exactly; votesCast includes abstains, so the abstain-vs-no-votes distinction is correct.
  • Kick sheet firing into a started game: the phaseAt guard blocks a lobby-kick confirm after the game starts (where the same tool would kill a seated player). Kicking a departed player surfaces the server's teaching error as a toast.
  • Poll/user-action races: in-flight poll landing during or after a user call can't roll the board back (version guard) and can't wipe an open sheet (renderSheet survives render(); phase-change cleanup closes it correctly).
  • Spectator reset copy: applyReset really does seat spectators by joinedSeq up to 80, so "dealt in when the room resets, if a seat is free" is accurate.
  • Bundle freshness: apphtml.generated.ts is untracked and rebuilt by prebuild/pretest/pretypecheck hooks, so the app changes ship.

Findings 1–2 are judgment calls on a latent path; 3–4 are one-liners. Nothing here blocks the merge.

@willtech3
willtech3 merged commit 8654477 into main Aug 16, 2026
2 checks passed
@willtech3
willtech3 deleted the polish/board-reveal-lobby-kick-poll branch August 16, 2026 09:45
willtech3 added a commit that referenced this pull request Aug 16, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant