Skip to content

fix(web): surface accessible illegal-move feedback - #87

Merged
sayed710 merged 8 commits into
mainfrom
claude/illegal-move-feedback
Oct 3, 2026
Merged

sayed710 merged 8 commits into
mainfrom
claude/illegal-move-feedback

Conversation

@sayed710

@sayed710 sayed710 commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Root cause (reverified on origin/main 2dd6d4d)

On our turn, BoardInteraction.attempt turned a destination the LegalMoveOracle did not offer into { kind: 'deselect' }. That is the same result as tapping the selected square again, so a rejected move was silent for mouse, touch, drag and keyboard alike. Server rejection presentation was already separate and is unchanged.

Contract

  • New GestureResult { kind: 'illegal', from, to }. It is returned only on our turn, when a selected or dragged own piece is sent to a destination absent from oracle.destinations(from) and the target is not another own piece. It carries no reason (the oracle exposes none).
  • Unchanged:
    • none: empty or opponent taps with nothing selected, and any gesture while a promotion is pending.
    • deselect: the same square again, or a drop on the source.
    • select: reselection by tap or drop.
    • premove: every off-turn destination.
    • Promotion and applyPremove.
  • setTurn now refreshes a selection that survives the turn arriving without a new position (readiness, an acknowledged move). Otherwise a legal move would have been reported illegal.
  • BoardView: an exhaustive switch (with a never default) and a separate typed onFeedback(MoveFeedback) channel. onResult still carries only moves to submit or queue, because mountBoard treats any non-move as a premove. There is no per-input legality code.
  • mountBoard: an optional feedbackEl. The game route passes #move-feedback, a role="status" polite region. It is separate from #status, which GameController rewrites on every sync.
    • Each rejection is a new node, so an identical repeat is announced again.
    • Focus never moves.
    • The message clears on any other gesture, a new position, a turn change or disposal/remount, and is retranslated in place on a locale change.
  • Boards with no real oracle are not wired: the fallback, studies, endgame and learning boards.
  • Copy: board.feedback.illegalMove = "That move isn’t legal." (English only, no coordinates). It reuses the muted count style: no animation and no colour-only signal.

Server authority

A rejected gesture never calls onMove, sends no WebSocket frame and creates no optimistic state; legal moves are submitted exactly as before. There is no API, socket, gateway, persistence or migration change.

Tests

  • RED first, on 2dd6d4d with the tests compiling: 5 interaction and 8 board tests failed on behaviour.
  • Pure layer (interaction.test.ts): tap and drag rejection, an uncapturable opponent piece, a move straight after, two oracles on the same gesture, a selection surviving the turn arriving, reselection by drop, off-turn premoves, and a pending promotion.
  • View (board-a11y.test.ts, real mountBoard + BoardView):
    • keyboard, click and drag rejection;
    • one announcement per gesture, and repeats (tap and drag) added as new nodes;
    • no onMove;
    • the roving tab stop stays on the attempted square;
    • reselection, deselection, empty taps, navigation and premoves stay silent;
    • clearing on position, turn and dispose;
    • relocalization with the test-only Arabic catalog;
    • exactly one announcement after remount.
  • Live stack (e2e-live-loop.test.ts, real GameAuthority):
    • Standard: e2–e5 is rejected with no frame and no pending move, then e2–e4 commits (ply 1).
    • Racing Kings: the knight's e2–c3 (which gives check) is rejected while the geometrically equivalent e2–d4 is submitted, which proves the distinction comes from the oracle.
  • Backend Playwright (illegal-move-feedback.spec.ts):
    • Keyboard rejection on a real game: the message shows, focus is kept, there is no move frame, the board and #status are unchanged, and a repeat is a new node. The next legal move commits ("Black to move") and sends exactly one frame. A spectator's gestures send nothing and show nothing.
    • Touch at 390 px under dir="rtl": no overflow, and the board stays at least 350 px wide.

Exact-head review corrections (de4ce7b)

Qodo found 3 bugs and Greptile 2 P2 findings on 6a8257d; all were valid and fixed with RED tests first:

  • A finished game is nobody's turn. ended empties legal moves but keeps turn, so after a resignation or flag on our turn every attempt read "not legal".
  • BoardView.setTurn re-renders a selection that the turn's arrival refreshed.
  • The message also clears on none results and when a drag starts.
  • The message uses the existing .error (Ember) primitive, which the design rules reserve for errors.

CI also failed: the topology guard pins the Playwright spec count. It is now 31, with the new spec asserted backend-only. My first local test:scripts run predated the spec file.

Second correction (5cb1485)

  • Qodo on de4ce7b: a finished board still accepted premoves. myTurn = false means "premove", and the route passes no playerColor. On main this already happened for endings on the opponent's turn.
  • BoardInteraction.setInputEnabled now turns input off for a finished game. The route sets it from isOver, which drops the selection, premoves and any pending promotion.
  • The browser spec resigns on each side's turn, and both cases were RED first.

Self-introduced regression, caught by the final gate. The new setTurn/setInputEnabled renders rebuilt the grid on every sync and action update, which detached cells under the board-geometry e2e. With the shield off it reproduced in 7 of 20 runs. Both now render only on a real change; the same set passed 20 of 20, and a unit test pins the behaviour.

Third correction (069cd6c)

Qodo on 5cb1485 (it also marked the premove finding resolved): a game ending mid-drag left the floating piece following the pointer until release. setInputEnabled(false) now removes the float and forgets the drag. A RED test came first, and a mutation removing the fix is killed. On this tree the final gates passed first time.

Fourth correction (0891257)

On 069cd6c, Greptile (5/5) found one issue and Qodo one bug; both were valid and fixed with RED tests first:

  • Greptile: the board's input toggle cleared the message on every action-state update, such as a draw offer, so a rejection could vanish unheard. It now returns early unless input actually changes. That is also the single guard against grid churn, so the view's duplicate check was removed.
  • Qodo: abandoning a drag (game end or destroy) left its window pointermove/pointerup listeners attached. A shared cancelDrag now removes the listeners, the floating piece and the drag state.

Integration with main

PR #88 merged first as f99a9e1 and took M15 Increment 85, migration 0048 and ADR-0155. I merged it normally in a0afb83, with no rebase or force.

Falsification

Final tree: 25 mutations, with sources backed up and restored by SHA-256, and none killed by compile errors.

  • 23 were killed by tests. One of them (the game route never disabling input) was checked with the backend browser spec.
  • 2 survive as equivalent mutants: "reuse the node on repeat" and "no clear on a legal move". Every rejection and every legal move needs a selecting gesture first, which already clears the message.

The original 13 were:

  • illegal back to deselect
  • illegal also submitted
  • keyboard rejection not announced
  • reselection reported illegal
  • premoves reported illegal
  • announcement dropped
  • node reused on repeat
  • not cleared after a legal move
  • view not destroyed on remount
  • Standard knight geometry beside the oracle
  • setTurn refresh removed
  • not cleared on a new position
  • not cleared on disposal

Validation

All runs were sequential, on the final integrated tree 8e556a3:

Check Result
build, lint, all check:*, check:test-topology pass
test:scripts 312
web unit 1,425
hermetic root, 19 workspaces 3,915, zero skips (integrated with #88)
static Playwright, --retries=0 187/187
backend Playwright, --workers=4 --retries=0 231/231, 0 skips

On the integrated tree both gates passed on the first run. On the earlier 5cb1485 tree the first backend run was invalidated, not failed: vite preview crashed natively (0xC0000409), leaving 17 passed and 214 ECONNREFUSED. The owner authorized exactly one unchanged rerun, which passed 231/231. Avast Web/Network Shield was off for the final browser runs only and is back on.

Not included

  • Reason text.
  • Feedback for a later-invalidated premove: no production caller applies premoves today, so this is separate scope.
  • Sound, animation, or Arabic production copy.
  • No independent model review: Codex model unsupported, Gemini CLI tier ineligible, agy quota 429.

The PR stays open for the owner to merge.

BoardInteraction turned an on-turn destination the LegalMoveOracle does
not offer into 'deselect', so a rejected move was indistinguishable from
an ordinary deselection. It now returns a typed 'illegal' result; premoves,
reselection, deselection, promotion and server authority are unchanged.

BoardView forwards it through a separate exhaustive onFeedback channel and
the game route renders it in its own polite live region (#move-feedback),
not the controller-owned #status. Each rejection is a fresh node so repeats
are re-announced; focus never moves; the message clears on any other
gesture, position, turn or disposal. setTurn now refreshes a selection that
survives the turn arriving, so a legal move is never reported illegal.
@coderabbitai

coderabbitai Bot commented Oct 2, 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: 3e8459ab-5d3d-4f48-84bc-1ed483fa5e80
  • 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

Copy link
Copy Markdown

PR Summary by Qodo

Surface accessible feedback for locally rejected chess moves

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Distinguish locally rejected moves from ordinary deselection without changing premoves or server
 authority.
• Announce rejections in a separate polite live region while preserving board focus.
• Cover gesture behavior, authoritative legality, accessibility, and browser layouts with new tests.
Diagram

graph TD
  A["Board gestures"] --> B["BoardInteraction"] --> O["LegalMoveOracle"] --> C{"Legal target?"} -->|No| D["BoardView feedback"] --> E["Polite live region"]
  C -->|Yes| F["Move submission"]
Loading
High-Level Assessment

Keep legality in the injected oracle and feedback separate from move submission and the controller-owned status. Reusing status risks erasing the announcement during sync; adding board-side chess rules would duplicate authority and mishandle variants.

Files changed (13) +656 / -18

Bug fix (7) +93 / -13
index.htmlAdd a dedicated move-feedback live region +1/-0

Add a dedicated move-feedback live region

• Adds a polite status region beside the game status so local rejection messages remain separate from controller-owned text.

packages/web/index.html

board.tsPresent and manage local move feedback +39/-4

Present and manage local move feedback

• Accepts an optional feedback element and inserts a fresh localized node for each rejection. Clears stale feedback on other dispatched gestures, position or turn updates, and teardown.

packages/web/src/app/board.ts

game-mount.tsConnect game boards to the feedback region +1/-1

Connect game boards to the feedback region

• Passes the game route's move-feedback element to the mounted board, leaving other board mounts unwired.

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

interaction.tsReturn an explicit illegal gesture result +12/-3

Return an explicit illegal gesture result

• Distinguishes rejected on-turn destinations from deselection without submitting a move. Refreshes surviving selection destinations when the turn changes.

packages/web/src/core/interaction.ts

en.tsAdd concise illegal-move copy +1/-0

Add concise illegal-move copy

• Adds the English catalog message used for local rejections without asserting a specific chess-rule reason.

packages/web/src/i18n/catalog/en.ts

style.cssCollapse the empty feedback region +5/-0

Collapse the empty feedback region

• Removes the live region's margin while it is empty, keeping it mounted without reserving space.

packages/web/src/style.css

board-view.tsRoute rejection feedback separately from moves +34/-5

Route rejection feedback separately from moves

• Adds a typed feedback callback and exhaustively handles gesture results. Only resolved moves and premoves reach the existing result callback.

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

Tests (5) +524 / -4
illegal-move-feedback.spec.tsTest rejected moves against a running backend +177/-0

Test rejected moves against a running backend

• Exercises keyboard rejection, repeated announcements, unchanged server state, subsequent legal submission, and spectator behavior. Also checks touch feedback and layout in a narrow right-to-left viewport.

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

playwright.config.tsInclude illegal-move coverage in backend browser tests +1/-0

Include illegal-move coverage in backend browser tests

• Registers the new Playwright spec in the backend test set.

packages/web/playwright.config.ts

board-a11y.test.tsTest live-region and focus behavior across inputs +211/-0

Test live-region and focus behavior across inputs

• Covers keyboard, click, and drag rejection; repeated announcements; silence for non-rejections; and legal moves afterward. Verifies clearing, relocalization, disposal, remounting, and roving focus.

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

e2e-live-loop.test.tsVerify rejection against authoritative legal moves +66/-0

Verify rejection against authoritative legal moves

• Checks that a rejected Standard move sends no frame or optimistic update before a legal move commits. A Racing Kings case verifies that variant legality comes from the authority's move map.

packages/web/test/e2e-live-loop.test.ts

interaction.test.tsSpecify illegal gesture and preservation cases +69/-4

Specify illegal gesture and preservation cases

• Tests explicit rejection for taps and drops, oracle-dependent legality, and a selection surviving a turn change. Preserves expectations for reselection, premoves, promotion, and a legal move after rejection.

packages/web/test/interaction.test.ts

Documentation (1) +39 / -1
PROJECT_STATE.mdRecord the illegal-move feedback milestone +39/-1

Record the illegal-move feedback milestone

• Documents the behavior contract, accessibility choices, test coverage, validation, and deliberate limits for Increment 85.

docs/PROJECT_STATE.md

@qodo-code-review

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Abandoned drags leave window listeners behind ✓ Resolved
Description
setInputEnabled(false) forgets the active drag without removing the pointermove and pointerup
listeners installed by handlePointerDown. If the game ends while the pointer is held, no later
pointer-up may arrive, so those closures remain attached for the lifetime of the page and can also
interfere with a later drag if input is re-enabled.
Code

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

+    if (!enabled) {
+      // A drag in progress ends here: drop the floating piece and forget the gesture, so later
+      // pointer events find nothing to move or release.
+      this.endFloat();
+      this.dragging = false;
+      this.dragFrom = null;
+    }
Relevance

●●● Strong

Accepted history favors explicit listener cleanup and lifecycle reliability fixes; this is a
concrete abandoned-drag resource leak.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
handlePointerDown installs pointermove and pointerup listeners on window, and those
listeners are removed only inside the local up callback. The new disabling path clears the drag
fields and float but does not retain or remove those callbacks, while destroy() likewise removes
only root listeners; therefore an interrupted drag with no subsequent pointer-up leaves window
listeners registered.

packages/web/src/ui/board-view.ts[270-291]
packages/web/src/ui/board-view.ts[135-154]

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

## Issue description
`setInputEnabled(false)` clears `dragging` and `dragFrom`, but an active drag's window-level `pointermove` and `pointerup` listeners are only removed inside the original `pointerup` callback. When input is disabled while the pointer remains held, those listeners can leak indefinitely and remain active if input is later re-enabled.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[270-310]
- packages/web/src/ui/board-view.ts[180-194]

## Recommended Fix
Store the active drag listener callbacks (or a single abort controller) on the `BoardView` instance. Add a shared drag-cancellation helper that removes both window listeners, clears the listener references, removes the floating element, and resets `dragging`, `dragFrom`, and `pointerId`. Call that helper from `setInputEnabled(false)` and `destroy()`, while keeping normal pointer-up cleanup behavior unchanged.

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


2. Finished boards keep a piece floating ✓ Resolved
Description
BoardView.setInputEnabled(false) clears interaction state and re-renders without ending an active
drag or removing its floating piece. If a game-ending update arrives after a player starts dragging,
the piece remains floating and follows pointer movement until a pointer-up event occurs, even though
the board no longer accepts input.
Code

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

+    if (!enabled && this.overlay) this.cancelPromotion();
+    this.interaction.setInputEnabled(enabled);
Relevance

●●● Strong

Active drag state and floating pieces must be cleared when disabling input; this is a deterministic
finished-board correctness bug.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A drag creates a floating piece and sets view-level drag state, while the new disable method only
cancels a promotion and delegates to the interaction layer. Later pointer moves still move the
float; pointer-up is the path that removes it. The game route invokes the disable method when action
state becomes finished.

packages/web/src/ui/board-view.ts[181-187]
packages/web/src/ui/board-view.ts[283-300]
packages/web/src/ui/board-view.ts[335-360]
packages/web/src/app/game-mount.ts[1476-1481]

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 game can end during a pointer drag. Disabling board input clears interaction state but leaves the view's active drag and floating piece visible until pointer-up.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[181-187]
- packages/web/src/ui/board-view.ts[283-344]

## Recommended Fix
When disabling input, end any active float and reset drag state before rendering. Ensure subsequent pointer events cannot resume the canceled drag, and add a test for an end-of-game update during a drag.

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


3. Finished games still accept premoves ✓ Resolved
Description
GameController passes false to the board when status.over is true, and BoardInteraction
interprets that value as permission to queue off-turn premoves. If a game ends while it is the
player's turn, selecting a piece and choosing a destination marks a premove on the finished board
and can display a premove-set status, even though no further move can be played.
Code

packages/web/src/app/game-controller.ts[427]

+      && state.status?.over !== true;
Relevance

●●● Strong

The PR explicitly fixes finished-game turn state and adds a regression test preventing premoves
after game end.

PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An ended broadcast sets status.over without changing turn and empties legal moves. The new
condition emits onTurn(false), which the game mount forwards to the board; its interaction layer
then takes the off-turn branch and enqueues a premove without checking the oracle. The board's
premove result handler also sets a premove status.

packages/web/src/net/game-sync.ts[382-390]
packages/web/src/app/game-controller.ts[420-432]
packages/web/src/app/game-mount.ts[1433-1436]
packages/web/src/core/interaction.ts[201-231]
packages/web/src/app/board.ts[197-219]

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 new finished-game condition makes the board treat an ended game as an off-turn game, allowing gestures to queue premoves and display premove feedback.

## Fix Focus Areas
- packages/web/src/app/game-controller.ts[420-432]
- packages/web/src/core/interaction.ts[168-231]
- packages/web/src/app/game-mount.ts[1433-1436]

## Recommended Fix
Pass a distinct ended or input-disabled state to the board and have its interaction layer ignore move gestures in that state. Preserve the existing off-turn premove behavior during live games, and test an ending that arrives on the player's turn.

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


View medium (2)
4. Turn changes hide newly legal squares ✓ Resolved
Description
BoardInteraction.setTurn() now recomputes destinations for a surviving selection, but
BoardView.setTurn() does not render the changed highlights. When an off-turn selection survives
the turn arriving without a new position, its legal destinations become usable while their square
styling and accessibility descriptions remain absent until another render.
Code

packages/web/src/core/interaction.ts[R102-105]

+    // The turn can arrive without a new position (readiness, an acknowledged move), so a selection
+    // made off-turn survives it. Judge that selection by this turn's destinations, not the empty
+    // off-turn list, or a legal move would be reported illegal.
+    if (this.selected !== null) this.setSelection(this.selected);
Relevance

●●● Strong

Turn refresh updates interaction state, but BoardView must rerender highlights and accessibility
labels immediately.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Off-turn selection stores no destinations; the new turn refresh populates them, but the view's turn
setter does not render. The legal-square styling and accessibility descriptions are produced during
rendering.

packages/web/src/core/interaction.ts[100-105]
packages/web/src/core/interaction.ts[157-166]
packages/web/src/ui/board-view.ts[172-175]
packages/web/src/ui/board-view.ts[449-476]

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

## Issue description
Refreshing a surviving selection on turn change updates interaction legality but leaves the rendered board showing its old, empty destination list.
## Fix Focus Areas
- packages/web/src/core/interaction.ts[100-105]
- packages/web/src/ui/board-view.ts[172-175]
## Recommended Fix
Render the board after updating the interaction's turn, and test both the usable destinations and their visual and accessible highlights when an off-turn selection survives a turn change.

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


5. Finished games report moves as illegal ✓ Resolved
Description
BoardInteraction.attempt() now labels an unavailable destination illegal, while the controller
can leave myTurn true after the game ends and the move oracle becomes empty. If a game ends while
it is the player's turn, attempting a move on the still-interactive board announces “That move isn’t
legal” rather than withholding local legality feedback.
Code

packages/web/src/core/interaction.ts[205]

+        return { kind: 'illegal', from, to };
Relevance

●● Moderate

Potential finished-game edge case, but no close historical precedent confirms suppressing local
legality feedback after termination.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An ending clears the authoritative legal moves without changing the turn. The controller's turn
calculation does not check whether the game is over, allowing the new illegal-result branch to reach
the feedback presenter.

packages/web/src/net/game-sync.ts[371-379]
packages/web/src/app/game-controller.ts[399-421]
packages/web/src/core/interaction.ts[192-205]
packages/web/src/app/board.ts[174-180]

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 game ending on the player's turn leaves the board enabled with an empty legality map, so subsequent gestures announce a misleading illegal-move message.
## Fix Focus Areas
- packages/web/src/core/interaction.ts[203-205]
- packages/web/src/app/game-controller.ts[399-421]
- packages/web/src/net/game-sync.ts[371-379]
## Recommended Fix
Make the controller's derived turn false when the game is over, so the board stops judging destinations against the emptied oracle; add a regression test for an ending received on the player's turn.

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



Informational

6. Old rejection message stays after some taps ✓ Resolved
Description
BoardView.dispatch clears feedback for select, deselect, move, premove and promotion,
but its none case only re-renders, and starting a drag never goes through dispatch. After a
rejection, tapping an empty or opponent square with nothing selected, any gesture while a promotion
is pending, or starting a drag leaves "That move isn’t legal." on screen, although the PR
description says it clears on any other gesture.
Code

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

+      case 'none':
+        this.render();
+        return;
Relevance

●●● Strong

The stated contract requires clearing feedback on every other gesture, including none results and
drag initiation.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
After a rejection, tap returns { kind: 'none' } when nothing is selected and the square is not
an own piece, and also whenever a promotion is pending. The none case renders without calling
onFeedback. handlePointerMove calls dragStart directly and never dispatches, so starting a
drag also leaves the message. The board-a11y drag test comment confirms that starting a drag clears
nothing.

packages/web/src/core/interaction.ts[167-178]
packages/web/src/ui/board-view.ts[300-313]
packages/web/src/ui/board-view.ts[333-346]

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

## Issue description
After a rejection, a `none` gesture (an empty or opponent tap with nothing selected) or the start of a drag leaves the stale "That move isn’t legal." message showing, which contradicts the documented 'clears on any other gesture' contract.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[335-337]
- packages/web/src/ui/board-view.ts[300-313]

## Recommended Fix
In `dispatch`, call `this.onFeedback({ kind: 'clear' })` in the `none` case before `render()`. Optionally also emit a clear when `dragStart` succeeds in `handlePointerMove`. Make sure a repeated identical drag still inserts a new node, because the clear removes the old node first. Add a board-a11y test for 'reject, then tap an empty square, and the message clears'. If you keep the current behaviour on purpose, change the documentation instead.

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


Grey Divider

Context sources
Review mode: 🧠 Deep: The latest push introduces substantial, concurrency- and persistence-sensitive retry, lease-fencing, cancellation, deployment, and diagnostic logic across many independent code paths, making redundant review materially valuable.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 8e556a3

Results up to commit 6a8257d 🧠 Deep


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


Remediation recommended
1. Turn changes hide newly legal squares ✓ Resolved
Description
BoardInteraction.setTurn() now recomputes destinations for a surviving selection, but
BoardView.setTurn() does not render the changed highlights. When an off-turn selection survives
the turn arriving without a new position, its legal destinations become usable while their square
styling and accessibility descriptions remain absent until another render.
Code

packages/web/src/core/interaction.ts[R102-105]

+    // The turn can arrive without a new position (readiness, an acknowledged move), so a selection
+    // made off-turn survives it. Judge that selection by this turn's destinations, not the empty
+    // off-turn list, or a legal move would be reported illegal.
+    if (this.selected !== null) this.setSelection(this.selected);
Relevance

●●● Strong

Turn refresh updates interaction state, but BoardView must rerender highlights and accessibility
labels immediately.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Off-turn selection stores no destinations; the new turn refresh populates them, but the view's turn
setter does not render. The legal-square styling and accessibility descriptions are produced during
rendering.

packages/web/src/core/interaction.ts[100-105]
packages/web/src/core/interaction.ts[157-166]
packages/web/src/ui/board-view.ts[172-175]
packages/web/src/ui/board-view.ts[449-476]

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

## Issue description
Refreshing a surviving selection on turn change updates interaction legality but leaves the rendered board showing its old, empty destination list.
## Fix Focus Areas
- packages/web/src/core/interaction.ts[100-105]
- packages/web/src/ui/board-view.ts[172-175]
## Recommended Fix
Render the board after updating the interaction's turn, and test both the usable destinations and their visual and accessible highlights when an off-turn selection survives a turn change.

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


2. Finished games report moves as illegal ✓ Resolved
Description
BoardInteraction.attempt() now labels an unavailable destination illegal, while the controller
can leave myTurn true after the game ends and the move oracle becomes empty. If a game ends while
it is the player's turn, attempting a move on the still-interactive board announces “That move isn’t
legal” rather than withholding local legality feedback.
Code

packages/web/src/core/interaction.ts[205]

+        return { kind: 'illegal', from, to };
Relevance

●● Moderate

Potential finished-game edge case, but no close historical precedent confirms suppressing local
legality feedback after termination.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An ending clears the authoritative legal moves without changing the turn. The controller's turn
calculation does not check whether the game is over, allowing the new illegal-result branch to reach
the feedback presenter.

packages/web/src/net/game-sync.ts[371-379]
packages/web/src/app/game-controller.ts[399-421]
packages/web/src/core/interaction.ts[192-205]
packages/web/src/app/board.ts[174-180]

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 game ending on the player's turn leaves the board enabled with an empty legality map, so subsequent gestures announce a misleading illegal-move message.
## Fix Focus Areas
- packages/web/src/core/interaction.ts[203-205]
- packages/web/src/app/game-controller.ts[399-421]
- packages/web/src/net/game-sync.ts[371-379]
## Recommended Fix
Make the controller's derived turn false when the game is over, so the board stops judging destinations against the emptied oracle; add a regression test for an ending received on the player's turn.

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



Informational
3. Old rejection message stays after some taps ✓ Resolved
Description
BoardView.dispatch clears feedback for select, deselect, move, premove and promotion,
but its none case only re-renders, and starting a drag never goes through dispatch. After a
rejection, tapping an empty or opponent square with nothing selected, any gesture while a promotion
is pending, or starting a drag leaves "That move isn’t legal." on screen, although the PR
description says it clears on any other gesture.
Code

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

+      case 'none':
+        this.render();
+        return;
Relevance

●●● Strong

The stated contract requires clearing feedback on every other gesture, including none results and
drag initiation.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
After a rejection, tap returns { kind: 'none' } when nothing is selected and the square is not
an own piece, and also whenever a promotion is pending. The none case renders without calling
onFeedback. handlePointerMove calls dragStart directly and never dispatches, so starting a
drag also leaves the message. The board-a11y drag test comment confirms that starting a drag clears
nothing.

packages/web/src/core/interaction.ts[167-178]
packages/web/src/ui/board-view.ts[300-313]
packages/web/src/ui/board-view.ts[333-346]

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

## Issue description
After a rejection, a `none` gesture (an empty or opponent tap with nothing selected) or the start of a drag leaves the stale "That move isn’t legal." message showing, which contradicts the documented 'clears on any other gesture' contract.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[335-337]
- packages/web/src/ui/board-view.ts[300-313]

## Recommended Fix
In `dispatch`, call `this.onFeedback({ kind: 'clear' })` in the `none` case before `render()`. Optionally also emit a clear when `dragStart` succeeds in `handlePointerMove`. Make sure a repeated identical drag still inserts a new node, because the clear removes the old node first. Add a board-a11y test for 'reject, then tap an empty square, and the message clears'. If you keep the current behaviour on purpose, change the documentation instead.

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


Results up to commit de4ce7b ⚖️ Balanced


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


Remediation recommended
1. Finished games still accept premoves ✓ Resolved
Description
GameController passes false to the board when status.over is true, and BoardInteraction
interprets that value as permission to queue off-turn premoves. If a game ends while it is the
player's turn, selecting a piece and choosing a destination marks a premove on the finished board
and can display a premove-set status, even though no further move can be played.
Code

packages/web/src/app/game-controller.ts[427]

+      && state.status?.over !== true;
Relevance

●●● Strong

The PR explicitly fixes finished-game turn state and adds a regression test preventing premoves
after game end.

PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
An ended broadcast sets status.over without changing turn and empties legal moves. The new
condition emits onTurn(false), which the game mount forwards to the board; its interaction layer
then takes the off-turn branch and enqueues a premove without checking the oracle. The board's
premove result handler also sets a premove status.

packages/web/src/net/game-sync.ts[382-390]
packages/web/src/app/game-controller.ts[420-432]
packages/web/src/app/game-mount.ts[1433-1436]
packages/web/src/core/interaction.ts[201-231]
packages/web/src/app/board.ts[197-219]

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 new finished-game condition makes the board treat an ended game as an off-turn game, allowing gestures to queue premoves and display premove feedback.

## Fix Focus Areas
- packages/web/src/app/game-controller.ts[420-432]
- packages/web/src/core/interaction.ts[168-231]
- packages/web/src/app/game-mount.ts[1433-1436]

## Recommended Fix
Pass a distinct ended or input-disabled state to the board and have its interaction layer ignore move gestures in that state. Preserve the existing off-turn premove behavior during live games, and test an ending that arrives on the player's turn.

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


Results up to commit 5cb1485 ⚖️ Balanced


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


Remediation recommended
1. Finished boards keep a piece floating ✓ Resolved
Description
BoardView.setInputEnabled(false) clears interaction state and re-renders without ending an active
drag or removing its floating piece. If a game-ending update arrives after a player starts dragging,
the piece remains floating and follows pointer movement until a pointer-up event occurs, even though
the board no longer accepts input.
Code

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

+    if (!enabled && this.overlay) this.cancelPromotion();
+    this.interaction.setInputEnabled(enabled);
Relevance

●●● Strong

Active drag state and floating pieces must be cleared when disabling input; this is a deterministic
finished-board correctness bug.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
A drag creates a floating piece and sets view-level drag state, while the new disable method only
cancels a promotion and delegates to the interaction layer. Later pointer moves still move the
float; pointer-up is the path that removes it. The game route invokes the disable method when action
state becomes finished.

packages/web/src/ui/board-view.ts[181-187]
packages/web/src/ui/board-view.ts[283-300]
packages/web/src/ui/board-view.ts[335-360]
packages/web/src/app/game-mount.ts[1476-1481]

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 game can end during a pointer drag. Disabling board input clears interaction state but leaves the view's active drag and floating piece visible until pointer-up.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[181-187]
- packages/web/src/ui/board-view.ts[283-344]

## Recommended Fix
When disabling input, end any active float and reset drag state before rendering. Ensure subsequent pointer events cannot resume the canceled drag, and add a test for an end-of-game update during a drag.

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


Results up to commit 069cd6c 🚀 Fast


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


Remediation recommended
1. Abandoned drags leave window listeners behind ✓ Resolved
Description
setInputEnabled(false) forgets the active drag without removing the pointermove and pointerup
listeners installed by handlePointerDown. If the game ends while the pointer is held, no later
pointer-up may arrive, so those closures remain attached for the lifetime of the page and can also
interfere with a later drag if input is re-enabled.
Code

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

+    if (!enabled) {
+      // A drag in progress ends here: drop the floating piece and forget the gesture, so later
+      // pointer events find nothing to move or release.
+      this.endFloat();
+      this.dragging = false;
+      this.dragFrom = null;
+    }
Relevance

●●● Strong

Accepted history favors explicit listener cleanup and lifecycle reliability fixes; this is a
concrete abandoned-drag resource leak.

PR-#15
PR-#50

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
handlePointerDown installs pointermove and pointerup listeners on window, and those
listeners are removed only inside the local up callback. The new disabling path clears the drag
fields and float but does not retain or remove those callbacks, while destroy() likewise removes
only root listeners; therefore an interrupted drag with no subsequent pointer-up leaves window
listeners registered.

packages/web/src/ui/board-view.ts[270-291]
packages/web/src/ui/board-view.ts[135-154]

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

## Issue description
`setInputEnabled(false)` clears `dragging` and `dragFrom`, but an active drag's window-level `pointermove` and `pointerup` listeners are only removed inside the original `pointerup` callback. When input is disabled while the pointer remains held, those listeners can leak indefinitely and remain active if input is later re-enabled.

## Fix Focus Areas
- packages/web/src/ui/board-view.ts[270-310]
- packages/web/src/ui/board-view.ts[180-194]

## Recommended Fix
Store the active drag listener callbacks (or a single abort controller) on the `BoardView` instance. Add a shared drag-cancellation helper that removes both window listeners, clears the listener references, removes the floating element, and resets `dragging`, `dragFrom`, and `pointerId`. Call that helper from `setInputEnabled(false)` and `destroy()`, while keeping normal pointer-up cleanup behavior unchanged.

ⓘ 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/core/interaction.ts
Comment thread packages/web/src/core/interaction.ts
Comment thread packages/web/src/ui/board-view.ts
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adds local feedback for rejected chess moves.

The PR appears safe to merge; no outstanding findings remain.

Summary

The PR adds accessible feedback for locally rejected moves while leaving server-authoritative move submission unchanged.

  • It gives illegal gestures a distinct result and announces them in a separate live region.
  • It disables board input after a game ends and cleans up abandoned drags.
  • It adds interaction, accessibility, live-loop, and browser coverage.

Reviews (3) · Last reviewed commit: "docs: record integrated validation for M..."

Comment thread packages/web/src/ui/board-view.ts
Comment thread packages/web/index.html Outdated
- A finished game is nobody's turn: 'ended' empties legal moves but keeps
  turn, so a resignation or flag on our turn made every attempt 'illegal'.
- BoardView.setTurn re-renders a selection refreshed by the turn arriving.
- The rejection also clears on 'none' results and when a drag starts.
- Use the existing .error (Ember) primitive for the message.
- Topology guard: 31 discovered Playwright specs; the new one is backend-only.
@sayed710

sayed710 commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

/review

Comment thread packages/web/src/app/game-controller.ts
@qodo-code-review

Copy link
Copy Markdown

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

- Qodo on de4ce7b: myTurn=false means premove, so a finished board still
  selected pieces, queued premoves and wrote 'Premove set' over the result
  (on main already for endings on the opponent's turn). BoardInteraction
  gains setInputEnabled; the game route sets it from isOver, which drops
  selection, premoves and a pending promotion.
- setTurn and setInputEnabled re-rendered on every sync/action update,
  detaching cells under the board-geometry e2e (7/20 with the shield off);
  they now render only when something visible changes.
@sayed710

sayed710 commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

/review

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 5cb1485

Qodo on 5cb1485: disabling input mid-drag left the floating piece following
the pointer until release. setInputEnabled(false) now removes the float and
forgets the drag, so later pointer events find nothing to move or release.
@sayed710

sayed710 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

/review

@sayed710

sayed710 commented Oct 3, 2026

Copy link
Copy Markdown
Owner 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 069cd6c

Comment thread packages/web/src/app/board.ts
…ed drags

- Greptile on 069cd6c: mountBoard.setInputEnabled cleared the rejection on
  every action-state update (draw offer, connection blip). It now returns
  early unless input actually changes; that is also the single churn guard,
  so the view's duplicate check is removed.
- Qodo on 069cd6c: abandoning a drag (game end or destroy) left its window
  pointermove/pointerup listeners attached. A shared cancelDrag removes the
  listeners, the floating piece and the drag state.
…eedback

# Conflicts:
#	docs/PROJECT_STATE.md
@sayed710

sayed710 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

/review

@sayed710

sayed710 commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

@greptileai review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8e556a3

@sayed710
sayed710 merged commit d5af5be into main Oct 3, 2026
11 checks passed
@sayed710
sayed710 deleted the claude/illegal-move-feedback branch October 3, 2026 02:54
edwardnewgate710 pushed a commit that referenced this pull request Oct 3, 2026
…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.
sayed710 added a commit that referenced this pull request Oct 4, 2026
* fix(web): enforce player ownership on board interaction

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.

* docs: record M15 Increment 87 player board ownership

* fix(web): drop the trailing click of a gesture cut short by an owner 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.

* docs: record the Increment 87 review correction and its validation

* fix(web): clear click suppression when the release that set it makes 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.

* docs: record the Greptile correction and Linux backend validation for Increment 87

* docs: correct Increment 87 over-claims and record the exact-head review

* fix(web): tie click suppression to the pointer that produced the click

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.

* docs: record the pointer-matched suppression fix and its Linux validation

* fix(web): bound the no-pointer-id click fallback and ignore other pointers' 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.

* docs: record the bounded click fallback and its Linux validation

* fix(web): end a drag on its own pointercancel and on a new press

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.

* docs: record the drag cancellation fix and its Linux validation

* fix(web): a new press abandons another pointer's gesture without acting 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.

* docs: record the abandoned-gesture fix and its Linux validation

* fix(web): track abandoned-gesture waits and click suppressions per pointer

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.

* docs: record per-pointer gesture tracking and its Linux validation

* fix(web): tie a pointer-less click to the last released pointer

Greptile on 3bd6965: on an engine whose clicks carry no pointerId, the
fallback matched clicks with a press count shared by all fingers. If
finger A was abandoned after finger B pressed and A released without a
click, A's suppression was recorded at B's press count and B's genuine
tap was swallowed.

A click without a pointer id is now taken to be the last released
pointer's (a browser dispatches a click straight after its own pointer's
release) and is swallowed only if that pointer is suppressed; the shared
press counter is gone. An awaited pointer's pointercancel now ends its
wait without recording a suppression, since a cancelled pointer never
clicks (the optional LOW from the independent review of 3bd6965).
Engines with pointer ids are unchanged.

* docs: record the last-release click fallback and its Linux validation

* fix(web): bound click records and put abandoned clicks first on engines 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.

* docs: record bounded click records and the safety-first pointer-less click rule

* fix(web): only recent releases can claim a pointer-less click

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.

* docs: record time-bounded pointer-less click matching and its Linux validation

* test(web): pin the pointer-less click window from both sides

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.

* docs: record the pinned click-window tests and their Linux validation

* docs: record the merge with #89 and its Linux validation

* fix(web): an abandoned release swallows every pointer-less click in its 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.

* docs(web): say when a suppressed click keeps its record

* docs: record the abandoned-click fix and its Linux validation

* docs: keep one current Increment 88 line in the project-state header

---------

Co-authored-by: Hussein Mohamed <americanopbr@gmail.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