Skip to content

fix(web): enforce player ownership on board interaction - #90

Merged
sayed710 merged 31 commits into
mainfrom
claude/player-board-ownership
Oct 4, 2026
Merged

sayed710 merged 31 commits into
mainfrom
claude/player-board-ownership

Conversation

@edwardnewgate710

Copy link
Copy Markdown
Collaborator

Summary

Spectators could select, drag and premove on the game board. The board was interactive before the server confirmed the player's role. An off-turn player could pick up the opponent's pieces (the side to move) and queue premoves with them. Joining a finished game briefly enabled input. No illegal move reached the server, because GameSync.submitMove already refused them, but the board offered gestures it had no right to.

Root cause: BoardInteraction.movableColor() fell back to sideToMove when no playerColor was given. The game route mounted its board without one and enabled input whenever !isOver.

Behavioural contract

  • Spectator / role not yet known (null owner): no selection, drag, drop, keyboard move, promotion or premove, so zero submissions. There is no "illegal move" message either. Focus, roving keyboard navigation and flipping still work.
  • Player: only their own colour, on and off turn. Own premoves (including promotion premoves), reselection and illegal-move feedback are unchanged.
  • Startup: fails closed until the joined role arrives, whichever order onColor and onActionState arrive in.
  • Finished: never re-enabled (latched); PR fix(web): surface accessible illegal-move feedback #87 behaviour is preserved.
  • Server authority, API and WebSocket: unchanged. Legality is still only the server's legal-move map, so no variant rules were added. Chess960 is covered.

Design

  • core/interaction.ts: explicit BoardOwner = Color | null | 'side-to-move'. The last value is kept only for boards without players (analysis, studies, endgame, learning), which are unchanged. setPlayerColor drops the selection, a pending promotion and premoves on a real change. drop() re-checks the origin's owner.
  • ui/board-view.ts: setPlayerColor also closes an open promotion chooser and abandons a drag.
  • app/board.ts: a playerColor mount option and setPlayerColor, with the same churn guard as setInputEnabled.
  • app/game-mount.ts: mounts with null. One syncBoardOwnership() reads myColor and status.over from a single GameSync snapshot and is called from both onColor and onActionState.

Tests

  • Core (board-ownership.test.ts, 14): spectators; fail-closed startup; White-only and Black-only on and off turn, including drops; own premove queued and applied; promotion premove; reselection; illegal feedback for a real player; finished board; owner changes clearing selection, promotion and premoves; Chess960; standalone board.
  • View (board-a11y.test.ts, +7): spectator click, keyboard and drag submit nothing and announce nothing; navigation, focus and flip on a read-only board; own colour only by click, keyboard and drag; owner change mid-drag and with the chooser open; churn guard; remount.
  • Route (board-ownership-route.test.ts, 10, real mountGame with a fake socket): before the socket opens and before join; spectators across syncs; legal move; off-turn White and Black; finished join for every role; finished wins; Chess960.
  • Backend Playwright (board-ownership.spec.ts): a real two-player game plus a spectator, with click, keyboard (Enter and Space) and drag. No move frames come from spectator or opponent-coloured attempts; own premoves queue without being sent; legal moves commit.
  • illegal-move-feedback.spec.ts: the finished-board step relied on the old fallback and would now pass vacuously, so it tries White's own premove instead.
  • RED on d5af5be: the route tests compile and 8 of 9 fail on assertions. The browser spec, run against main's sources, fails because off-turn Black queued a premove of White's e2–e4.
  • Mutations: 16 compiled mutations, 15 killed by tests. The survivor (removing playerColor: null at mount) is equivalent: controller.start() nulls the owner synchronously inside mountGame. The option is kept as defense in depth.

Validation (exact head)

  • build, lint, all 8 check:* guards, test:scripts 312
  • web unit 1,456; hermetic 3,946 across 19 workspaces, 0 skips
  • static Playwright 187/187 (4 workers, --retries=0; Avast Web/Network Shield off at the owner's direction)
  • backend Playwright 232/232 (4 workers, --retries=0, normal host state with the shield on). Ports were checked free first, and a read-only commit sampler showed vite preview alive from start to teardown.
  • Earlier backend runs are disclosed in full in docs/PROJECT_STATE.md (Increment 87): 231/232 (Chromium launch fault); two runs invalidated by the separate vite preview 0xC0000409 crash (that diagnostic closed with no product defect and no proven root cause); 230/232 (achievements loopback timeout plus a Chromium launch fault). No config, timeout, retry or worker changes were made.

Independent review

  • Implementation review: Gemini 3.8 Flash High was quota-blocked (429), so Claude Sonnet 4.6 via agy ran it. Of 6 findings, 1 valid one was fixed (a missing drag-premove test) and 5 were rejected with reasons (see PROJECT_STATE).
  • Exact-final-head review of 6068e0b: Gemini was 429 (resets in about 98h) and Sonnet 4.6 was also 429 (resets in about 1h33m). Per the review hierarchy this fell back to a strict self-review: it re-checked all six earlier findings and every factual claim in Increment 87, and found no blocking issues.

Out of scope (pre-existing, disclosed)

  • No production caller applies queued premoves.
  • Study and lesson boards use setTurn(false), which means premove mode, not read-only.
  • The vite preview native crash.

Do not merge from automation: the owner merges manually.

BoardInteraction fell back to the side to move when no player colour was
given, and the game route mounted its board without one and enabled input
whenever the game was not over. Spectators could select, drag and premove;
the board was interactive before the joined role arrived; an off-turn
player could pick up the opponent's pieces and queue premoves with them;
and a finished-game join briefly enabled input.

- Ownership is explicit: a colour, null (nobody) or 'side-to-move' (boards
  without players only). setPlayerColor drops the selection, a pending
  promotion and queued premoves on a real change; drop() re-checks the
  origin's owner. Legality stays oracle-driven for every variant.
- BoardView.setPlayerColor closes an open promotion chooser and abandons a
  drag; focus, keyboard navigation and flip are untouched. mountBoard
  exposes it with the same churn guard as setInputEnabled.
- The game route mounts fail-closed and derives owner and input from one
  GameSync snapshot from both onColor and onActionState, so callback order
  cannot open a window; a finished game latches.

Tests: core, view and route suites plus a backend Playwright spec with a
real two-player game and a spectator (click, keyboard and drag). The
finished-board step of illegal-move-feedback.spec.ts relied on the old
fallback and now tries White's own premove.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Enforce player ownership for game-board interactions

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Keep game boards read-only until a live player's role is confirmed; spectators cannot move pieces.
• Limit players to their own pieces while preserving premoves and standalone-board behavior.
• Add core, route, accessibility, and browser coverage for ownership and finished games.
Diagram

graph TD
  S["GameSync snapshot"] --> R["Game route"] --> M["Board mount"] --> V["Board view"] --> I["Interaction core"] --> O{"Owned piece?"} -->|"Yes, on turn"| L["Move oracle"]
  O -->|"No"| N["No action"]
Loading
High-Level Assessment

The approach fits the existing architecture: enforce piece ownership in the shared interaction core, derive the game-board owner from GameSync, and leave legality with the move oracle. View-only gesture checks would need duplication across click, keyboard, and drag; relying on server rejection would not prevent misleading local interactions.

Files changed (12) +913 / -22

Bug fix (4) +88 / -11
board.tsExpose ownership on mounted boards +19/-1

Expose ownership on mounted boards

• Adds an optional initial player color and a mounted-board setter. Unspecified ownership retains standalone side-to-move behavior; repeated assignments avoid clearing feedback or rerendering.

packages/web/src/app/board.ts

game-mount.tsSynchronize game-board ownership from server state +17/-2

Synchronize game-board ownership from server state

• Mounts game boards without an owner until the joined role is known. Both role and action-state callbacks derive ownership and input enablement from one GameSync snapshot, and a finished game cannot be re-enabled.

packages/web/src/app/game-mount.ts

interaction.tsRequire an owner for piece interactions +40/-8

Require an owner for piece interactions

• Distinguishes a player color, no owner, and standalone side-to-move ownership. Owner changes clear in-progress interactions and premoves; drops recheck the origin's ownership without changing oracle-based legality.

packages/web/src/core/interaction.ts

board-view.tsCancel transient UI when board ownership changes +12/-0

Cancel transient UI when board ownership changes

• Adds a view-level ownership setter that dismisses promotion UI and abandons an active drag before updating the interaction core. Focus, navigation, and orientation remain available on read-only boards.

packages/web/src/ui/board-view.ts

Tests (6) +777 / -10
board-ownership.spec.tsExercise player and spectator gestures in a live game +160/-0

Exercise player and spectator gestures in a live game

• Adds a backend Playwright scenario with two players and a spectator. It checks click, keyboard, and drag behavior, move frames, own-color premoves, and continued spectator read-only access after moves.

packages/web/e2e/board-ownership.spec.ts

illegal-move-feedback.spec.tsUse an own-color premove to test finished-board input +6/-6

Use an own-color premove to test finished-board input

• Changes the finished-game assertion to attempt White's own off-turn premove rather than an opponent piece, so it still tests that finished boards reject otherwise available input.

packages/web/e2e/illegal-move-feedback.spec.ts

board-a11y.test.tsCover ownership across accessible board gestures +147/-2

Cover ownership across accessible board gestures

• Adds fake-DOM tests for spectator click, keyboard, and drag blocking while preserving navigation and flipping. Also covers own-color premoves, ownership changes during drag or promotion, no-op owner updates, and remounts.

packages/web/test/board-a11y.test.ts

board-ownership-route.test.tsVerify ownership at the game-route boundary +277/-0

Verify ownership at the game-route boundary

• Adds socket-driven route tests for fail-closed startup, spectators, both player colors, finished-game latching, and Chess960. Tests inspect board gestures and submissions before GameSync can reject a move.

packages/web/test/board-ownership-route.test.ts

board-ownership.test.tsTest the interaction core's ownership contract +183/-0

Test the interaction core's ownership contract

• Covers spectator inactivity, player-only selection and premoves, ownership transitions, promotion, finished-board behavior, Chess960, and the standalone-board fallback.

packages/web/test/board-ownership.test.ts

check-test-topology.test.mjsAssert discovery of the new backend browser spec +4/-2

Assert discovery of the new backend browser spec

• Updates Playwright discovery counts and verifies that the ownership spec is included in the full suite but excluded from the backend-free suite.

scripts/test/check-test-topology.test.mjs

Documentation (1) +47 / -1
PROJECT_STATE.mdRecord the board-ownership milestone +47/-1

Record the board-ownership milestone

• Documents the ownership contract, affected layers, test coverage, validation, and deliberate limits.

docs/PROJECT_STATE.md

Other (1) +1 / -0
playwright.config.tsInclude board ownership in backend browser tests +1/-0

Include board ownership in backend browser tests

• Registers the new ownership spec in the backend-dependent Playwright suite.

packages/web/playwright.config.ts

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ddc8dc75-f44d-479d-8318-4c8a739773ec
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

qodo-code-review Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. An abandoned tap can select a piece ✓ Resolved
Description
consumeSuppressedClick() deletes an abandoned pointer’s record when it sees any pointer-less
click, without knowing which finger produced that click. If a newer finger’s click arrives before
the abandoned finger’s delayed click, the newer tap is swallowed and the abandoned click reaches
interaction.tap() under the current owner.
Code

packages/web/src/ui/board-view.ts[R434-435]

+    for (const [pointerId, record] of this.suppressedClicks) {
+      if (record.abandoned) return this.suppressedClicks.delete(pointerId);
Relevance

●●● Strong

Valid pointer-less multi-finger correctness bug; closely aligned with accepted gesture-suppression
fixes and requires a regression test.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An on-board abandoned release creates the record, but the new pointer-less branch deletes it for the
first click regardless of its source. Once deleted, the later click passes through the board’s click
handler as a tap. The existing interleaved-finger test checks only the opposite click order.

packages/web/src/ui/board-view.ts[389-395]
packages/web/src/ui/board-view.ts[425-439]
packages/web/src/ui/board-view.ts[260-266]
packages/web/test/board-a11y.test.ts[1365-1377]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
On engines whose clicks lack pointer IDs, a newer finger’s click can consume an abandoned finger’s suppression record before the abandoned click arrives. The latter click can then act on the board.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[429-438]
- packages/web/test/board-a11y.test.ts[1365-1377]

## Recommended Fix
Add a regression test in which finger B’s pointer-less click arrives before abandoned finger A’s delayed click. Keep the abandoned suppression effective for both ambiguous clicks within the bounded click window, rather than deleting it on the first pointer-less click; retain the existing behavior for clicks with pointer IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. A later board activation can vanish ✓ Resolved
Description
consumeSuppressedClick() attributes any click without pointer fields to lastReleasedPointer,
even when another pointer has pressed and been cancelled since that release. If a drag ends
off-board without producing a click, that release leaves a suppression entry; after a subsequent
cancelled press, a plain click from assistive technology or programmatic activation consumes the
stale entry instead of activating its square.
Code

packages/web/src/ui/board-view.ts[R400-401]

+    const pointerId = click.pointerId ?? this.lastReleasedPointer;
+    return pointerId !== null && this.suppressedClicks.delete(pointerId);
Relevance

●●● Strong

Concrete stale-pointer correctness bug; closely matches this PR’s accepted click-suppression fixes
and needs a regression test.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A drag ending off-board still records suppression, while the new fallback retains that pointer as
the last release. An intervening press and cancellation do not change lastReleasedPointer; a later
plain click therefore deletes the stale entry and returns before interaction.tap(). The existing
cancellation test starts with no stale suppression, so it does not cover this sequence.

packages/web/src/ui/board-view.ts[331-335]
packages/web/src/ui/board-view.ts[396-401]
packages/web/src/ui/board-view.ts[445-462]
packages/web/src/ui/board-view.ts[240-247]
packages/web/test/board-a11y.test.ts[1191-1200]
packages/web/test/board-a11y.test.ts[1335-1344]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The pointer-less click fallback can mistake an unrelated plain click for a suppressed drag's click after another pointer has pressed and been cancelled.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[396-401]
- packages/web/src/ui/board-view.ts[315-320]
- packages/web/test/board-a11y.test.ts[1323-1345]

## Recommended Fix
Track whether a new press has occurred since the release associated with the fallback. Do not consume an id-less click using that stale release after an intervening press, while preserving suppression of a click from a release that follows another pointer's press. Add a regression test for an off-board release, another pointer's press and cancellation, then a plain click.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Off-board drags accumulate click records ✓ Resolved
Description
handlePointerUp() records a suppression entry even when the drag ends outside the board, and
handleAwaitedRelease() does the same for a cancelled pointer. Those endings produce no click on
the board to consume the entry, so successive touch pointers can grow suppressedClicks for the
lifetime of a mounted board.
Code

packages/web/src/ui/board-view.ts[451]

+    this.suppressClickFrom(event.pointerId);
Relevance

●●● Strong

Off-board releases leave per-pointer suppression entries indefinitely; this is a concrete lifetime
memory leak requiring cleanup.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The map is populated at every completed drag release before the target check, including the branch
where target is null. Entries are removed only by a matching click or a new board press with the
same pointer ID; neither occurs for an off-board release followed by new touch pointers.

packages/web/src/ui/board-view.ts[444-457]
packages/web/src/ui/board-view.ts[315-317]
packages/web/src/ui/board-view.ts[365-395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Suppression entries remain in the map when an off-board drop or pointer cancellation cannot produce a click on the board.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[365-369]
- packages/web/src/ui/board-view.ts[444-456]
## Recommended Fix
Record suppression only for releases that can produce a click handled by this board. Do not add an entry for pointer cancellation or an off-board drop, and test repeated no-click endings with distinct pointer IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
4. A second finger ends the wrong gesture ✓ Resolved
Description
awaitCancelledRelease() responds to any window pointerup or pointercancel without checking the
pointer that the owner change cancelled. If a second finger ends first, it removes the wait and
starts the suppression timer; the original finger's later release can then deliver an unsuppressed
click to the newly owned board.
Code

packages/web/src/ui/board-view.ts[R348-352]

+  private awaitCancelledRelease(): void {
+    this.releaseCancelledGesture?.();
+    const end = (): void => {
+      this.releaseCancelledGesture?.();
+      this.suppressReleaseClick();
Relevance

●●● Strong

Recent exact-head review explicitly identified missing pointerId filtering as a release-wait
correctness defect.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The view stores the active pointerId, but the newly added waiter's end callback accepts every
release or cancellation event. Ending the wait removes both listeners; once its timer clears
suppression, a later click reaches interaction.tap() under the new owner.

packages/web/src/ui/board-view.ts[306-323]
packages/web/src/ui/board-view.ts[334-360]
packages/web/src/ui/board-view.ts[235-245]
packages/web/src/core/interaction.ts[217-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A different pointer's release can end the cancelled-gesture wait, leaving the original gesture's click unsuppressed.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[198-205]
- packages/web/src/ui/board-view.ts[306-323]
- packages/web/src/ui/board-view.ts[347-360]
## Recommended Fix
Capture the cancelled gesture's pointer ID before cancelling its drag. End its wait only on that pointer's release or cancellation, and test interleaved releases from two pointers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Touch drags can activate a square twice ✓ Resolved
Description
suppressReleaseClick() clears suppressClick on a zero-delay timer, rather than when the
release's click is handled. If a touch-generated click arrives after that timer, handleClick()
treats it as a fresh tap, so a completed drag can also select a square or produce another board
gesture.
Code

packages/web/src/ui/board-view.ts[R340-344]

+  private suppressReleaseClick(): void {
+    this.suppressClick = true;
+    setTimeout(() => {
+      this.suppressClick = false;
+    }, 0);
Relevance

●●● Strong

Recent exact-head review identified this touch-click timing defect; suppression must wait for the
synthesized click.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The release path sets suppression and schedules its unconditional reset, while the click handler
calls interaction.tap() once suppression has cleared. The cited Pointer Events discussion records
that Chrome dispatches touch clicks asynchronously after pointer-up, unlike the assumed mouse
ordering.

packages/web/src/ui/board-view.ts[235-245]
packages/web/src/ui/board-view.ts[334-345]
packages/web/src/ui/board-view.ts[386-403]
🌐 A Chromium contributor states that touch clicks are dispatched asynchronously after pointer-up, in contrast to mouse clicks.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Touch-generated clicks can arrive after the zero-delay suppression timer and be handled as new board gestures.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[334-361]
- packages/web/src/ui/board-view.ts[386-403]
## Recommended Fix
Track the release and its resulting click without assuming they occur in the same task. Preserve the off-board and assistive-technology behavior, and add a browser touch test with a delayed click.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Cancelled drags can select new pieces ✓ Resolved
Description
BoardView.setPlayerColor() cancels the drag but does not suppress its trailing click, so
handleClick() can treat that click as a fresh tap. If ownership changes while the pointer is down
and the click lands on a piece owned by the newly confirmed player, the cancelled gesture selects
that piece.
Code

packages/web/src/ui/board-view.ts[198]

+    this.cancelDrag();
Evidence
The new owner-change path calls cancelDrag(), which clears drag state without setting
suppressClick. handlePointerUp() sets that flag only for a drag that remains active; otherwise a
subsequent click reaches interaction.tap(), which can select a piece belonging to the new owner.

packages/web/src/ui/board-view.ts[195-200]
packages/web/src/ui/board-view.ts[217-231]
packages/web/src/ui/board-view.ts[327-366]
packages/web/src/core/interaction.ts[224-238]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An ownership change cancels an active pointer gesture, but its trailing click can select a piece under the new owner.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[195-200]
- packages/web/src/ui/board-view.ts[217-231]
- packages/web/src/ui/board-view.ts[327-366]

## Recommended Fix
When an ownership change cancels an active pointer gesture, ensure any click generated by that gesture is ignored. Preserve ordinary clicks after the cancelled gesture, and add a test that changes ownership between pointer down and pointer up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. Project-state header fills with superseded lines ✓ Resolved
Description
The PR adds twelve "Prior: _Last updated …_ Increment 88" lines to the header of
docs/PROJECT_STATE.md. Each one is a superseded intermediate commit of this same increment, not an
earlier milestone. The file says it is the only document to read before resuming work, so anyone
resuming must skip past a dozen stale status lines to reach the previous milestones' history.
Code

docs/PROJECT_STATE.md[R11-14]

+Prior: _Last updated: 2026-10-04 — M15 Increment 88: click-window tests pinned and full Linux validation._
+
+Prior: _Last updated: 2026-10-04 — M15 Increment 88: time-bounded pointer-less click matching and full Linux validation._
+
Relevance

●●● Strong

Accepted docs-history precedents favor concise, accurate PROJECT_STATE handovers and correcting
misleading or noisy status documentation.

PR-#78
PR-#42
PR-#33

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Lines 11–31 of the new header are all Increment 88 variants ("click-window tests pinned",
"time-bounded pointer-less click matching", …), and each is superseded by line 9. The detailed
history is already kept in the Increment 88 body section (lines 5040+).

docs/PROJECT_STATE.md[6-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The header of docs/PROJECT_STATE.md gained 12 'Prior:' lines that are all intermediate states of Increment 88. That history is already recorded in the increment's body section.

## Fix Focus Areas
- docs/PROJECT_STATE.md[9-33]

## Recommended Fix
Keep the single current 'Last updated' line for Increment 88, then main's previous line (Increment 87, 'Windows SIGTERM harness external review corrections') as the first 'Prior:' line. Remove the intermediate Increment 88 'Prior:' lines.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: Auto: ⏭️ Skipped: The latest push changes only project-state documentation and has no runtime, configuration, test, or other behavioral effect.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display settings with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 33736e6

Results up to commit 6068e0b 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Cancelled drags can select new pieces ✓ Resolved
Description
BoardView.setPlayerColor() cancels the drag but does not suppress its trailing click, so
handleClick() can treat that click as a fresh tap. If ownership changes while the pointer is down
and the click lands on a piece owned by the newly confirmed player, the cancelled gesture selects
that piece.
Code

packages/web/src/ui/board-view.ts[198]

+    this.cancelDrag();
Evidence
The new owner-change path calls cancelDrag(), which clears drag state without setting
suppressClick. handlePointerUp() sets that flag only for a drag that remains active; otherwise a
subsequent click reaches interaction.tap(), which can select a piece belonging to the new owner.

packages/web/src/ui/board-view.ts[195-200]
packages/web/src/ui/board-view.ts[217-231]
packages/web/src/ui/board-view.ts[327-366]
packages/web/src/core/interaction.ts[224-238]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An ownership change cancels an active pointer gesture, but its trailing click can select a piece under the new owner.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[195-200]
- packages/web/src/ui/board-view.ts[217-231]
- packages/web/src/ui/board-view.ts[327-366]

## Recommended Fix
When an ownership change cancels an active pointer gesture, ensure any click generated by that gesture is ignored. Preserve ordinary clicks after the cancelled gesture, and add a test that changes ownership between pointer down and pointer up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 38e1bdd ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. A second finger ends the wrong gesture ✓ Resolved
Description
awaitCancelledRelease() responds to any window pointerup or pointercancel without checking the
pointer that the owner change cancelled. If a second finger ends first, it removes the wait and
starts the suppression timer; the original finger's later release can then deliver an unsuppressed
click to the newly owned board.
Code

packages/web/src/ui/board-view.ts[R348-352]

+  private awaitCancelledRelease(): void {
+    this.releaseCancelledGesture?.();
+    const end = (): void => {
+      this.releaseCancelledGesture?.();
+      this.suppressReleaseClick();
Relevance

●●● Strong

Recent exact-head review explicitly identified missing pointerId filtering as a release-wait
correctness defect.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The view stores the active pointerId, but the newly added waiter's end callback accepts every
release or cancellation event. Ending the wait removes both listeners; once its timer clears
suppression, a later click reaches interaction.tap() under the new owner.

packages/web/src/ui/board-view.ts[306-323]
packages/web/src/ui/board-view.ts[334-360]
packages/web/src/ui/board-view.ts[235-245]
packages/web/src/core/interaction.ts[217-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A different pointer's release can end the cancelled-gesture wait, leaving the original gesture's click unsuppressed.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[198-205]
- packages/web/src/ui/board-view.ts[306-323]
- packages/web/src/ui/board-view.ts[347-360]
## Recommended Fix
Capture the cancelled gesture's pointer ID before cancelling its drag. End its wait only on that pointer's release or cancellation, and test interleaved releases from two pointers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Touch drags can activate a square twice ✓ Resolved
Description
suppressReleaseClick() clears suppressClick on a zero-delay timer, rather than when the
release's click is handled. If a touch-generated click arrives after that timer, handleClick()
treats it as a fresh tap, so a completed drag can also select a square or produce another board
gesture.
Code

packages/web/src/ui/board-view.ts[R340-344]

+  private suppressReleaseClick(): void {
+    this.suppressClick = true;
+    setTimeout(() => {
+      this.suppressClick = false;
+    }, 0);
Relevance

●●● Strong

Recent exact-head review identified this touch-click timing defect; suppression must wait for the
synthesized click.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The release path sets suppression and schedules its unconditional reset, while the click handler
calls interaction.tap() once suppression has cleared. The cited Pointer Events discussion records
that Chrome dispatches touch clicks asynchronously after pointer-up, unlike the assumed mouse
ordering.

packages/web/src/ui/board-view.ts[235-245]
packages/web/src/ui/board-view.ts[334-345]
packages/web/src/ui/board-view.ts[386-403]
🌐 A Chromium contributor states that touch clicks are dispatched asynchronously after pointer-up, in contrast to mouse clicks.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Touch-generated clicks can arrive after the zero-delay suppression timer and be handled as new board gestures.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[334-361]
- packages/web/src/ui/board-view.ts[386-403]
## Recommended Fix
Track the release and its resulting click without assuming they occur in the same task. Preserve the off-board and assistive-technology behavior, and add a browser touch test with a delayed click.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 3bd6965 ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Off-board drags accumulate click records ✓ Resolved
Description
handlePointerUp() records a suppression entry even when the drag ends outside the board, and
handleAwaitedRelease() does the same for a cancelled pointer. Those endings produce no click on
the board to consume the entry, so successive touch pointers can grow suppressedClicks for the
lifetime of a mounted board.
Code

packages/web/src/ui/board-view.ts[451]

+    this.suppressClickFrom(event.pointerId);
Relevance

●●● Strong

Off-board releases leave per-pointer suppression entries indefinitely; this is a concrete lifetime
memory leak requiring cleanup.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The map is populated at every completed drag release before the target check, including the branch
where target is null. Entries are removed only by a matching click or a new board press with the
same pointer ID; neither occurs for an off-board release followed by new touch pointers.

packages/web/src/ui/board-view.ts[444-457]
packages/web/src/ui/board-view.ts[315-317]
packages/web/src/ui/board-view.ts[365-395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Suppression entries remain in the map when an off-board drop or pointer cancellation cannot produce a click on the board.
## Fix Focus Areas
- packages/web/src/ui/board-view.ts[365-369]
- packages/web/src/ui/board-view.ts[444-456]
## Recommended Fix
Record suppression only for releases that can produce a click handled by this board. Do not add an entry for pointer cancellation or an off-board drop, and test repeated no-click endings with distinct pointer IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 8314d11 ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. A later board activation can vanish ✓ Resolved
Description
consumeSuppressedClick() attributes any click without pointer fields to lastReleasedPointer,
even when another pointer has pressed and been cancelled since that release. If a drag ends
off-board without producing a click, that release leaves a suppression entry; after a subsequent
cancelled press, a plain click from assistive technology or programmatic activation consumes the
stale entry instead of activating its square.
Code

packages/web/src/ui/board-view.ts[R400-401]

+    const pointerId = click.pointerId ?? this.lastReleasedPointer;
+    return pointerId !== null && this.suppressedClicks.delete(pointerId);
Relevance

●●● Strong

Concrete stale-pointer correctness bug; closely matches this PR’s accepted click-suppression fixes
and needs a regression test.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A drag ending off-board still records suppression, while the new fallback retains that pointer as
the last release. An intervening press and cancellation do not change lastReleasedPointer; a later
plain click therefore deletes the stale entry and returns before interaction.tap(). The existing
cancellation test starts with no stale suppression, so it does not cover this sequence.

packages/web/src/ui/board-view.ts[331-335]
packages/web/src/ui/board-view.ts[396-401]
packages/web/src/ui/board-view.ts[445-462]
packages/web/src/ui/board-view.ts[240-247]
packages/web/test/board-a11y.test.ts[1191-1200]
packages/web/test/board-a11y.test.ts[1335-1344]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The pointer-less click fallback can mistake an unrelated plain click for a suppressed drag's click after another pointer has pressed and been cancelled.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[396-401]
- packages/web/src/ui/board-view.ts[315-320]
- packages/web/test/board-a11y.test.ts[1323-1345]

## Recommended Fix
Track whether a new press has occurred since the release associated with the fallback. Do not consume an id-less click using that stale release after an intervening press, while preserving suppression of a click from a release that follows another pointer's press. Add a regression test for an off-board release, another pointer's press and cancellation, then a plain click.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 431df30 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. An abandoned tap can select a piece ✓ Resolved
Description
consumeSuppressedClick() deletes an abandoned pointer’s record when it sees any pointer-less
click, without knowing which finger produced that click. If a newer finger’s click arrives before
the abandoned finger’s delayed click, the newer tap is swallowed and the abandoned click reaches
interaction.tap() under the current owner.
Code

packages/web/src/ui/board-view.ts[R434-435]

+    for (const [pointerId, record] of this.suppressedClicks) {
+      if (record.abandoned) return this.suppressedClicks.delete(pointerId);
Relevance

●●● Strong

Valid pointer-less multi-finger correctness bug; closely aligned with accepted gesture-suppression
fixes and requires a regression test.

PR-#87

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An on-board abandoned release creates the record, but the new pointer-less branch deletes it for the
first click regardless of its source. Once deleted, the later click passes through the board’s click
handler as a tap. The existing interleaved-finger test checks only the opposite click order.

packages/web/src/ui/board-view.ts[389-395]
packages/web/src/ui/board-view.ts[425-439]
packages/web/src/ui/board-view.ts[260-266]
packages/web/test/board-a11y.test.ts[1365-1377]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
On engines whose clicks lack pointer IDs, a newer finger’s click can consume an abandoned finger’s suppression record before the abandoned click arrives. The latter click can then act on the board.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[429-438]
- packages/web/test/board-a11y.test.ts[1365-1377]

## Recommended Fix
Add a regression test in which finger B’s pointer-less click arrives before abandoned finger A’s delayed click. Keep the abandoned suppression effective for both ambiguous clicks within the bounded click window, rather than deleting it on the first pointer-less click; retain the existing behavior for clicks with pointer IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit cbb06a2 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Informational
1. Project-state header fills with superseded lines ✓ Resolved
Description
The PR adds twelve "Prior: _Last updated …_ Increment 88" lines to the header of
docs/PROJECT_STATE.md. Each one is a superseded intermediate commit of this same increment, not an
earlier milestone. The file says it is the only document to read before resuming work, so anyone
resuming must skip past a dozen stale status lines to reach the previous milestones' history.
Code

docs/PROJECT_STATE.md[R11-14]

+Prior: _Last updated: 2026-10-04 — M15 Increment 88: click-window tests pinned and full Linux validation._
+
+Prior: _Last updated: 2026-10-04 — M15 Increment 88: time-bounded pointer-less click matching and full Linux validation._
+
Relevance

●●● Strong

Accepted docs-history precedents favor concise, accurate PROJECT_STATE handovers and correcting
misleading or noisy status documentation.

PR-#78
PR-#42
PR-#33

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Lines 11–31 of the new header are all Increment 88 variants ("click-window tests pinned",
"time-bounded pointer-less click matching", …), and each is superseded by line 9. The detailed
history is already kept in the Increment 88 body section (lines 5040+).

docs/PROJECT_STATE.md[6-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The header of docs/PROJECT_STATE.md gained 12 'Prior:' lines that are all intermediate states of Increment 88. That history is already recorded in the increment's body section.

## Fix Focus Areas
- docs/PROJECT_STATE.md[9-33]

## Recommended Fix
Keep the single current 'Last updated' line for Increment 88, then main's previous line (Increment 87, 'Windows SIGTERM harness external review corrections') as the first 'Prior:' line. Remove the intermediate Increment 88 'Prior:' lines.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread packages/web/src/ui/board-view.ts Outdated
@greptile-apps

greptile-apps Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Adds player ownership checks to board interaction.

The PR appears safe to merge; the latest change introduces no identified behavioral regression.

Summary

The PR makes game boards read-only until a live player’s colour is confirmed, restricts gestures to that player’s pieces, and preserves interaction on standalone boards. Since the previous review, the only change consolidates the project-state header; no new source or test behavior changed.

Reviews (8) · Last reviewed commit: "docs: keep one current Increment 88 line..."

…change

Qodo on 6068e0b: BoardView.setPlayerColor cancelled an in-progress
pointer gesture, which removed its pointer-up handler, so the gesture's
trailing click reached tap() and could select the new owner's piece
(e.g. a swipe begun before `joined` landed). An owner change during a
gesture now suppresses that click, and every pointerdown resets the
suppression so an abandoned gesture cannot swallow the next real click.
@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a623b11

Comment thread packages/web/src/ui/board-view.ts Outdated
…no click

Greptile on a623b11: a gesture cut short by an owner change and released
off the board left suppressClick set, so the next click with no pointer
events (assistive technology, programmatic activation) was swallowed. An
ordinary drag dropped off the board leaked the same way.

Suppression now covers only the click the release itself produces: it
clears on the next tick, since the browser dispatches that click straight
after pointer-up. An owner change waits for the cut-short gesture's
release (or pointercancel) instead of setting a sticky flag; the wait ends
at that release, at the next pointerdown, or on destroy.
@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Comment thread packages/web/src/ui/board-view.ts Outdated
Comment thread packages/web/src/ui/board-view.ts Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 38e1bdd

Comment thread packages/web/src/ui/board-view.ts Outdated
Qodo and Greptile on 38e1bdd: clearing suppression on a next-tick timer
assumed a release's click arrives in the same task, which touch does not
guarantee, so a late touch click from a completed drag or from a gesture
an owner change cut short could still act. Qodo also found that the
cut-short wait ended on any pointer's release, so a second finger lifting
first let the original finger's click through.

Suppression now names the pointer whose next click must be swallowed and
matches the click's own pointerId whenever it arrives. A click with no
pointer behind it (pointerType '', e.g. assistive technology) is never
swallowed. The wait ends only on that pointer's release or cancel, a new
press by the same pointer, or destroy; a new press by the same pointer also
clears a stale entry from a release that made no click. Engines whose
clicks carry no pointer fields keep suppressing the release's own click.
…nters' drag releases

Independent exact-head review of 79686a5:
- On an engine whose click carries no pointerId, a stale suppression
  (e.g. a touch drag released off the board, which makes no click) matched
  the next tap's click and swallowed it once. In that fallback a click now
  counts as the release's own only if no press came after the release
  (a press counter, not a timer). Engines with pointer ids are unchanged.
- The drag's pointerup listener accepted any pointer, so another finger's
  release could drop the carried piece at its own coordinates. It now
  ignores releases from other pointers.
Independent exact-head review of 066f046: drags never listened for
pointercancel, so a touch drag the browser took over (a pan, a system
gesture) stayed live with its floating piece; after the drag's pointerup
started ignoring other pointers, no later event recovered it. A new press
during a live drag also dropped the old drag's listeners without removing
its floating piece.

A drag now cancels on its own pointer's pointercancel, like a drop off the
board (selection cleared, floating piece removed). A press that never
became a drag keeps an existing selection. A new press calls cancelDrag()
before starting, so no floating piece is left behind.
…ng on it

Independent exact-head review of dd52355: a press during another pointer's
live drag only detached that drag's listeners. Its selection stayed, its
release went unwatched, and its trailing click reached tap() with the
piece still selected, so an abandoned drag could submit a move.

Owner changes and new presses now share abandonGesture(): wait for the
abandoned pointer's release and swallow its click, and undo a drag it
started (selection cleared, re-rendered). A re-press by the same pointer
(its release was lost) only resets, so its own click still works.
…inter

Independent exact-head review of b1441a4: a single wait slot and a single
suppression slot meant a third simultaneous pointer could defeat them
(abandoning A replaced the pending wait on C, so C's click still tapped),
and setInputEnabled(false) still only cancelled a drag.

Waits are now a set of awaited pointer ids served by one window listener
pair (attached while any is awaited), and suppressions a map of pointer id
to the press count at its release. Each pointer's release, click,
re-press and the legacy no-pointer-id fallback work as before, per pointer.
setInputEnabled(false) now abandons the gesture like an owner change.

The PR #87 test that asserted no pointerup listener after a game ends now
asserts the new contract: drag listeners go at once, and only the wait for
the abandoned release remains until that release.
@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Comment thread packages/web/src/ui/board-view.ts Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8314d11

Comment thread packages/web/src/ui/board-view.ts Outdated
…es without pointer ids

Qodo on 8314d11: off-board drops (and, earlier, cancelled pointers)
recorded click suppressions that no click could ever consume, and touch
pointer ids are never reused, so records grew for the life of the board.
Releases off the board now record nothing, and pending records are capped
(oldest evicted), which also bounds on-board touch drags that make no click.

Greptile on 8314d11: on an engine whose clicks carry no pointerId, finger
A's abandoned gesture could produce a delayed click after finger B's
release, and the last-released rule attributed it to B, so it acted.
Without a pointer id a click cannot be attributed, so a choice is
unavoidable: safety first. While an abandoned gesture's click is pending,
a pointer-less click is taken to be that one (an abandoned gesture never
acts; at worst one genuine tap is swallowed and repeated). Drag-release
suppressions keep the last-released rule. Engines with pointer ids are
unchanged.

Tests now create stale entries with on-board no-click releases so every
protection stays reachable; a redundant re-add line and a dead write are
removed.
Independent exact-head review of 39c2f9d: on an engine whose clicks carry
no pointerId (reportedly including Safari/iOS, unverified), an abandoned
touch that never produced a click left an abandoned record with no
expiry, so each later genuine tap was swallowed against one stale record,
up to the cap: dead taps spread over any amount of time.

Records now carry their release's time, and on the pointer-less path
records older than CLICK_WINDOW_MS (1 s: a release's click comes within
milliseconds, or ~300 ms when touch holds it for double-tap detection)
are dropped before matching. Times come from the events' own timeStamp.
Engines with pointer ids are unchanged.
Independent exact-head review of 242ad1c: only one test used explicit event times, so shrinking CLICK_WINDOW_MS (e.g. to 50 ms) or flipping its comparison went unnoticed. An abandoned release's click 300 ms later must still be swallowed, and a click just past the window must act.
Resolves docs/PROJECT_STATE.md only: #89's M15 Increment 87 entry and header lines are kept exactly; this PR's entry and its own header lines are renumbered to M15 Increment 88.
@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Comment thread packages/web/src/ui/board-view.ts Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 431df30

…ts window

On engines whose clicks carry no pointer id, a newer finger's click could
use up an abandoned finger's record before that finger's delayed click
arrived, letting the abandoned click act. The record now stays until its
window closes, so both ambiguous clicks are swallowed in either order.
@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Comment thread docs/PROJECT_STATE.md Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit cbb06a2

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

/review

@edwardnewgate710

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 33736e6

@sayed710
sayed710 merged commit 59ee749 into main Oct 4, 2026
11 checks passed
@sayed710
sayed710 deleted the claude/player-board-ownership branch October 4, 2026 09:00
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.

2 participants