Skip to content

feat(chess): add responsive mobile board interface#691

Merged
gabito1451 merged 1 commit into
OpenKnight-Foundation:mainfrom
raymondjoseph02:feat/responsive-mobile-board
Apr 28, 2026
Merged

feat(chess): add responsive mobile board interface#691
gabito1451 merged 1 commit into
OpenKnight-Foundation:mainfrom
raymondjoseph02:feat/responsive-mobile-board

Conversation

@raymondjoseph02

@raymondjoseph02 raymondjoseph02 commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Enhances the play page and chessboard component for mobile UX.

Changes

components/chess/ChessboardComponent.tsx

  • Added boardRef to compute square coordinates from touch position
  • Added touchStartSquare ref to track lifted piece
  • Added hoveredSquare state — renders teal inset ring on target square during drag
  • Wired onTouchStart per square, onTouchMove/onTouchEnd on board container

app/play/[slug]/page.tsx

  • Added mobile action row (lg:hidden) directly below board — exposes Flip, Resign, and connection status without scrolling
  • Desktop controls (hidden lg:flex) unchanged for ≥1024px
  • Move history header is now a toggle button on mobile (isMoveHistoryOpen state)
  • Socket status indicator hidden from Game Status card on mobile (shown in action row instead)

Testing

  • Desktop: no visual change, sidebar controls intact
  • Mobile (< 1024px): action row visible below board, move history collapsible
  • Touch drag: tap piece → drag → teal highlight tracks finger → release to move

Close #580

Summary by CodeRabbit

Release Notes

  • New Features
    • Added touch-based piece movement on mobile devices with visual hover feedback and smooth animations
    • Introduced collapsible move history panel optimized for mobile layouts
    • New mobile control bar featuring resign, flip board, and connection status indicator
    • Enhanced responsive design with mobile and desktop-specific UI elements

- Add touch drag support to ChessboardComponent (touchstart/move/end)
- Highlight target square during touch drag with teal inset ring
- Add mobile action row (Flip + Resign + status) below board on mobile
- Make move history collapsible via toggle on mobile
- Hide desktop controls on mobile (lg:hidden/hidden lg:flex)
- Hide socket status indicator from Game Status card on mobile
@drips-wave

drips-wave Bot commented Apr 26, 2026

Copy link
Copy Markdown

@raymondjoseph02 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This pull request enhances the chess application with mobile responsiveness and touch-based interaction. The play page now detects mobile layout, adds a collapsible move history section, and implements a mobile control bar beneath the board. The chessboard component adds touch event handling with visual feedback for square selection and piece movement.

Changes

Cohort / File(s) Summary
Mobile Responsive Page Layout
frontend/app/play/[slug]/page.tsx
Introduces mobile detection via useIsMobile hook, adds state for collapsible move history, implements mobile-exclusive control bar with resign button (conditional on game status), flip board placeholder, and socket status indicator. Desktop sidebar controls are now gated behind hidden lg:flex styling.
Touch-Based Chessboard Interaction
frontend/components/chess/ChessboardComponent.tsx
Adds touch event listeners to the chessboard for square selection and piece movement. Tracks touch start square and hovered square state; touch move computes the current square under the finger with visual box-shadow feedback. Touch end calculates move from initial to final position and invokes attemptMove. Includes CSS transition for smooth box-shadow animation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰✨ A board that bends to every touch,
Mobile magic, not too much!
Squares glimmer as fingers dance,
Responsive layouts take their chance.
Chess finds home on screens so small! 🎮

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements the core mobile board interface requirements [#580] but lacks unit tests and comprehensive documentation as specified in acceptance criteria. Add unit tests for touch interactions and mobile layout logic, and ensure code includes JSDoc comments documenting the new touch handlers and mobile state management.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(chess): add responsive mobile board interface' accurately reflects the main changes: adding responsive mobile functionality to the chess board and play page.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the responsive mobile board interface [#580]; no out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
frontend/components/chess/ChessboardComponent.tsx (2)

278-320: Touch path won't respect a future board flip.

attemptMove derives algebraic squares from row/col with 8 - row / 97 + col, which assumes white-on-bottom orientation. The PR's mobile control bar already exposes a "Flip Board" button (Line 292 of page.tsx); once that is wired to a boardOrientation prop, both getSquareFromTouch and the click path will produce inverted moves for the black-on-bottom case. Worth threading orientation through attemptMove (and the touch indices) when implementing flip so this code doesn't silently break.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/chess/ChessboardComponent.tsx` around lines 278 - 320,
Touch handlers and getSquareFromTouch assume white-on-bottom and will produce
inverted moves when boardOrientation is flipped; update the touch-to-square
mapping and move invocation to respect orientation by either: 1) making
getSquareFromTouch accept the boardOrientation prop and flip the computed
row/col when boardOrientation === "black" (e.g., row -> 7 - row, col -> 7 -
col), and ensure handleTouchStart/handleTouchMove/handleTouchEnd use that
oriented mapping; or 2) change attemptMove to accept an orientation parameter
and convert source/target rows/cols inside attemptMove using the same flip logic
before computing algebraic squares; modify the references to getSquareFromTouch
and attemptMove accordingly (functions: getSquareFromTouch, handleTouchStart,
handleTouchMove, handleTouchEnd, attemptMove) so the flipped board produces
correct moves.

289-296: Unused e parameter — and consider useState instead of a ref for touchStartSquare if you want it to drive UI.

Currently e is unused; either prefix with _ or drop it. Also, touchStartSquare is a ref so changes don't re-render — fine for this implementation, just confirming it's intentional that no UI reflects the start square distinctly from selectedSquare.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/chess/ChessboardComponent.tsx` around lines 289 - 296,
The handleTouchStart function currently declares an unused parameter e; remove
it or rename it to _e to signal it's unused, and keep the guard and calls to
touchStartSquare.current and setSelectedSquare as-is; if you intend the touch
start to drive UI updates (so changes should re-render), replace the
touchStartSquare ref with React state (e.g., useState) and update that state
inside handleTouchStart instead of assigning touchStartSquare.current. Ensure
you update any consumers of touchStartSquare to read from the new state
variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/play/`[slug]/page.tsx:
- Around line 290-313: The "Flip Board" button currently has an empty onClick
handler; either wire it to toggle the board orientation or disable it until
implemented. Add or reuse a boolean state like boardOrientation (or create a
toggleBoardOrientation handler) and pass boardOrientation into the
ChessboardComponent, then change onClick={() => {}} to
onClick={toggleBoardOrientation}; alternatively set disabled={true} and adjust
classes to show the disabled state for both the mobile button here and the
corresponding desktop button (same "Flip Board" control in the desktop block) so
the UI does not present a non-functional control.
- Around line 356-366: The header button currently uses onClick={() => isMobile
&& setIsMoveHistoryOpen(...)} which makes it focusable and clickable on desktop
but a no-op; change this so the interactive button is only rendered when mobile
(use a conditional to render the <button> with onClick that toggles
isMoveHistoryOpen and render a plain <h3> for desktop) or make the element
non-interactive on desktop (remove button role/handlers when !isMobile). Also
add id="move-history-list" to the moves container <div> and prefer CSS-driven
visibility for desktop (e.g., ensure the container uses a lg:!block or
equivalent class so layout doesn’t rely on isMobile during hydration). Ensure
you update references to isMoveHistoryOpen and setIsMoveHistoryOpen accordingly.
- Around line 52-53: The hydration flicker is caused by using the client-only
useIsMobile to toggle classes; replace the JS-driven visibility class logic that
references isMobile and isMoveHistoryOpen with Tailwind responsive utilities
(e.g., use "hidden lg:block" on the move-history panel and "lg:hidden" for
mobile-only elements) so the server and client markup match, and change the
toggle button's aria-expanded to rely solely on isMoveHistoryOpen (remove any
isMobile-based conditional) so aria-expanded no longer flips during hydration.

In `@frontend/components/chess/ChessboardComponent.tsx`:
- Around line 278-287: The touch-to-square mapping in getSquareFromTouch
currently uses the full bounding rect (boardRef and getBoundingClientRect) which
includes the container's 1% padding, causing incorrect col/row math; fix it by
computing the inner content box before mapping: read the board element's
computed padding (e.g., via getComputedStyle on boardRef.current) or calculate
padding pixels from rect.width/height and the known 1% value, subtract
left/right and top/bottom padding to build an innerRect (innerLeft, innerTop,
innerWidth, innerHeight), then compute x = touch.clientX - innerLeft and y =
touch.clientY - innerTop and derive col/row as Math.floor((x / innerWidth) * 8)
and Math.floor((y / innerHeight) * 8), returning null when outside 0–7; update
getSquareFromTouch to use this inner content rect.
- Around line 307-320: The touchend handler handleTouchEnd should call
e.preventDefault() to suppress the synthetic click that re-selects the moved
piece: inside handleTouchEnd (before/after computing sq) when a destination
square is detected and you call attemptMove, invoke e.preventDefault() so
handleSquareClick does not receive a synthetic click after the move; ensure
touchStartSquare.current, setSelectedSquare(null), and setHoveredSquare(null)
logic remains unchanged so state clearing still happens.

---

Nitpick comments:
In `@frontend/components/chess/ChessboardComponent.tsx`:
- Around line 278-320: Touch handlers and getSquareFromTouch assume
white-on-bottom and will produce inverted moves when boardOrientation is
flipped; update the touch-to-square mapping and move invocation to respect
orientation by either: 1) making getSquareFromTouch accept the boardOrientation
prop and flip the computed row/col when boardOrientation === "black" (e.g., row
-> 7 - row, col -> 7 - col), and ensure
handleTouchStart/handleTouchMove/handleTouchEnd use that oriented mapping; or 2)
change attemptMove to accept an orientation parameter and convert source/target
rows/cols inside attemptMove using the same flip logic before computing
algebraic squares; modify the references to getSquareFromTouch and attemptMove
accordingly (functions: getSquareFromTouch, handleTouchStart, handleTouchMove,
handleTouchEnd, attemptMove) so the flipped board produces correct moves.
- Around line 289-296: The handleTouchStart function currently declares an
unused parameter e; remove it or rename it to _e to signal it's unused, and keep
the guard and calls to touchStartSquare.current and setSelectedSquare as-is; if
you intend the touch start to drive UI updates (so changes should re-render),
replace the touchStartSquare ref with React state (e.g., useState) and update
that state inside handleTouchStart instead of assigning
touchStartSquare.current. Ensure you update any consumers of touchStartSquare to
read from the new state variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34439467-39c5-41ab-97e9-71c90ba4e65f

📥 Commits

Reviewing files that changed from the base of the PR and between 382408d and 42ee4df.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/app/play/[slug]/page.tsx
  • frontend/components/chess/ChessboardComponent.tsx

Comment on lines +52 to +53
const [isMoveHistoryOpen, setIsMoveHistoryOpen] = useState(false);
const isMobile = useIsMobile();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'use-mobile*' frontend/hook frontend/hooks 2>/dev/null
fd -t f 'use-mobile' | head
fd -t f 'use-mobile' --exec cat {}

Repository: NOVUS-X/XLMate

Length of output: 661


🏁 Script executed:

cat -n frontend/app/play/[slug]/page.tsx | sed -n '50,70p'

Repository: NOVUS-X/XLMate

Length of output: 837


🏁 Script executed:

cat -n frontend/app/play/[slug]/page.tsx | sed -n '360,375p'

Repository: NOVUS-X/XLMate

Length of output: 931


🏁 Script executed:

grep -n "aria-expanded" frontend/app/play/[slug]/page.tsx

Repository: NOVUS-X/XLMate

Length of output: 130


🏁 Script executed:

head -5 frontend/app/play/[slug]/page.tsx

Repository: NOVUS-X/XLMate

Length of output: 208


Hydration mismatch causing first-paint flicker via useIsMobile.

The page renders with isMobile=false (hook's initial state), then re-renders on the client after useEffect resolves the actual mobile status. This creates two issues:

  • The move-history panel at line 366 briefly displays on mobile (hidden class is false server-side, then true after hydration)
  • The aria-expanded attribute at line 359 flips from true (server) to isMoveHistoryOpen (client mobile)

Replace the JS-driven visibility toggle with Tailwind's responsive utilities: use hidden lg:block / lg:hidden for the panel instead of isMobile && !isMoveHistoryOpen ? "hidden" : "" to eliminate the flash and markup divergence between server and client renders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/play/`[slug]/page.tsx around lines 52 - 53, The hydration
flicker is caused by using the client-only useIsMobile to toggle classes;
replace the JS-driven visibility class logic that references isMobile and
isMoveHistoryOpen with Tailwind responsive utilities (e.g., use "hidden
lg:block" on the move-history panel and "lg:hidden" for mobile-only elements) so
the server and client markup match, and change the toggle button's aria-expanded
to rely solely on isMoveHistoryOpen (remove any isMobile-based conditional) so
aria-expanded no longer flips during hydration.

Comment on lines +290 to +313
{/* Mobile controls — visible only on mobile, directly below board */}
<div className="flex items-center gap-2 mt-3 lg:hidden">
<button
onClick={() => {}}
aria-label="Flip board orientation"
className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300"
>
Flip Board
</button>
<button
onClick={handleResign}
disabled={gameStatus !== "playing"}
aria-label="Resign game"
className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Resign
</button>
<div className="flex items-center gap-1.5 px-2 shrink-0">
<FaSignal className={`text-xs ${socketStatusColor()}`} />
<span className={`text-xs ${socketStatusColor()}`}>
{socketStatusLabel()}
</span>
</div>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

"Flip Board" button is a non-functional placeholder.

onClick={() => {}} ships a visibly-active control that does nothing when tapped, which is a poor UX (and the same placeholder exists at Line 404 in the desktop block). Either wire it to a real flip handler (a boardOrientation state passed to ChessboardComponent) or disable/omit the button until the feature lands.

Suggested interim fix (disable until implemented)
-              <button
-                onClick={() => {}}
-                aria-label="Flip board orientation"
-                className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300"
-              >
+              <button
+                type="button"
+                onClick={() => {}}
+                disabled
+                aria-label="Flip board orientation (coming soon)"
+                className="flex-1 py-2.5 rounded-xl bg-gray-800/60 border border-gray-700/50 text-gray-300 text-sm font-medium opacity-50 cursor-not-allowed"
+              >
                 Flip Board
               </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{/* Mobile controls — visible only on mobile, directly below board */}
<div className="flex items-center gap-2 mt-3 lg:hidden">
<button
onClick={() => {}}
aria-label="Flip board orientation"
className="flex-1 py-2.5 rounded-xl bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/50 text-gray-300 text-sm font-medium transition-all duration-300"
>
Flip Board
</button>
<button
onClick={handleResign}
disabled={gameStatus !== "playing"}
aria-label="Resign game"
className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Resign
</button>
<div className="flex items-center gap-1.5 px-2 shrink-0">
<FaSignal className={`text-xs ${socketStatusColor()}`} />
<span className={`text-xs ${socketStatusColor()}`}>
{socketStatusLabel()}
</span>
</div>
</div>
{/* Mobile controls — visible only on mobile, directly below board */}
<div className="flex items-center gap-2 mt-3 lg:hidden">
<button
type="button"
onClick={() => {}}
disabled
aria-label="Flip board orientation (coming soon)"
className="flex-1 py-2.5 rounded-xl bg-gray-800/60 border border-gray-700/50 text-gray-300 text-sm font-medium opacity-50 cursor-not-allowed"
>
Flip Board
</button>
<button
onClick={handleResign}
disabled={gameStatus !== "playing"}
aria-label="Resign game"
className="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Resign
</button>
<div className="flex items-center gap-1.5 px-2 shrink-0">
<FaSignal className={`text-xs ${socketStatusColor()}`} />
<span className={`text-xs ${socketStatusColor()}`}>
{socketStatusLabel()}
</span>
</div>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/play/`[slug]/page.tsx around lines 290 - 313, The "Flip Board"
button currently has an empty onClick handler; either wire it to toggle the
board orientation or disable it until implemented. Add or reuse a boolean state
like boardOrientation (or create a toggleBoardOrientation handler) and pass
boardOrientation into the ChessboardComponent, then change onClick={() => {}} to
onClick={toggleBoardOrientation}; alternatively set disabled={true} and adjust
classes to show the disabled state for both the mobile button here and the
corresponding desktop button (same "Flip Board" control in the desktop block) so
the UI does not present a non-functional control.

Comment on lines +356 to +366
<button
className="flex items-center justify-between w-full text-left"
onClick={() => isMobile && setIsMoveHistoryOpen((prev) => !prev)}
aria-expanded={isMobile ? isMoveHistoryOpen : true}
>
<h3 className="text-sm font-semibold text-gray-300">Moves</h3>
<span className="text-gray-500 text-xs lg:hidden">
{isMoveHistoryOpen ? "▲" : "▼"}
</span>
</button>
<div className={`max-h-64 overflow-y-auto space-y-0.5 mt-3 ${isMobile && !isMoveHistoryOpen ? "hidden" : ""}`}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Toggle button is focusable & clickable on desktop but does nothing.

onClick={() => isMobile && setIsMoveHistoryOpen(...)} makes the entire header act as a button on every viewport, but the click is a no-op on lg+. Users navigating by keyboard get a focus ring on a control that produces no effect. Either render a plain <h3> on desktop and the button only on mobile, or short-circuit interactivity via the element itself.

Suggested fix
-              <button
-                className="flex items-center justify-between w-full text-left"
-                onClick={() => isMobile && setIsMoveHistoryOpen((prev) => !prev)}
-                aria-expanded={isMobile ? isMoveHistoryOpen : true}
-              >
-                <h3 className="text-sm font-semibold text-gray-300">Moves</h3>
-                <span className="text-gray-500 text-xs lg:hidden">
-                  {isMoveHistoryOpen ? "▲" : "▼"}
-                </span>
-              </button>
+              <button
+                type="button"
+                className="flex items-center justify-between w-full text-left lg:cursor-default lg:pointer-events-none"
+                onClick={() => setIsMoveHistoryOpen((prev) => !prev)}
+                aria-expanded={isMoveHistoryOpen}
+                aria-controls="move-history-list"
+              >
+                <h3 className="text-sm font-semibold text-gray-300">Moves</h3>
+                <span className="text-gray-500 text-xs lg:hidden" aria-hidden="true">
+                  {isMoveHistoryOpen ? "▲" : "▼"}
+                </span>
+              </button>

(Then add id="move-history-list" to the list <div> at Line 366 and prefer pure CSS visibility — e.g., lg:!block — over the JS isMobile check to avoid hydration flicker.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/play/`[slug]/page.tsx around lines 356 - 366, The header button
currently uses onClick={() => isMobile && setIsMoveHistoryOpen(...)} which makes
it focusable and clickable on desktop but a no-op; change this so the
interactive button is only rendered when mobile (use a conditional to render the
<button> with onClick that toggles isMoveHistoryOpen and render a plain <h3> for
desktop) or make the element non-interactive on desktop (remove button
role/handlers when !isMobile). Also add id="move-history-list" to the moves
container <div> and prefer CSS-driven visibility for desktop (e.g., ensure the
container uses a lg:!block or equivalent class so layout doesn’t rely on
isMobile during hydration). Ensure you update references to isMoveHistoryOpen
and setIsMoveHistoryOpen accordingly.

Comment on lines +278 to +287
const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => {
if (!boardRef.current) return null;
const rect = boardRef.current.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const col = Math.floor((x / rect.width) * 8);
const row = Math.floor((y / rect.height) * 8);
if (col < 0 || col > 7 || row < 0 || row > 7) return null;
return [row, col];
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Coordinate mapping ignores the container's 1% padding.

boardRef.current.getBoundingClientRect() returns the full container including the padding: "1%" set at Line 351, but the 8×8 grid cells only occupy the inner content box. Dividing (x / rect.width) * 8 therefore stretches the grid mapping across the padded area, so:

  • touches landing inside the padding strip map to column/row 0 or 7 (outside the actual square boundary), and
  • every square is slightly off-aligned from its visual edges, biasing larger errors toward the borders.

Subtract the padding (or read the inner cell rect) before computing indices.

Suggested fix
   const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => {
     if (!boardRef.current) return null;
-    const rect = boardRef.current.getBoundingClientRect();
-    const x = touch.clientX - rect.left;
-    const y = touch.clientY - rect.top;
-    const col = Math.floor((x / rect.width) * 8);
-    const row = Math.floor((y / rect.height) * 8);
+    const el = boardRef.current;
+    const rect = el.getBoundingClientRect();
+    const cs = window.getComputedStyle(el);
+    const padX = parseFloat(cs.paddingLeft) || 0;
+    const padY = parseFloat(cs.paddingTop)  || 0;
+    const innerW = rect.width  - padX - (parseFloat(cs.paddingRight)  || 0);
+    const innerH = rect.height - padY - (parseFloat(cs.paddingBottom) || 0);
+    const x = touch.clientX - rect.left - padX;
+    const y = touch.clientY - rect.top  - padY;
+    const col = Math.floor((x / innerW) * 8);
+    const row = Math.floor((y / innerH) * 8);
     if (col < 0 || col > 7 || row < 0 || row > 7) return null;
     return [row, col];
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => {
if (!boardRef.current) return null;
const rect = boardRef.current.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const col = Math.floor((x / rect.width) * 8);
const row = Math.floor((y / rect.height) * 8);
if (col < 0 || col > 7 || row < 0 || row > 7) return null;
return [row, col];
}, []);
const getSquareFromTouch = useCallback((touch: React.Touch): [number, number] | null => {
if (!boardRef.current) return null;
const el = boardRef.current;
const rect = el.getBoundingClientRect();
const cs = window.getComputedStyle(el);
const padX = parseFloat(cs.paddingLeft) || 0;
const padY = parseFloat(cs.paddingTop) || 0;
const innerW = rect.width - padX - (parseFloat(cs.paddingRight) || 0);
const innerH = rect.height - padY - (parseFloat(cs.paddingBottom) || 0);
const x = touch.clientX - rect.left - padX;
const y = touch.clientY - rect.top - padY;
const col = Math.floor((x / innerW) * 8);
const row = Math.floor((y / innerH) * 8);
if (col < 0 || col > 7 || row < 0 || row > 7) return null;
return [row, col];
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/chess/ChessboardComponent.tsx` around lines 278 - 287,
The touch-to-square mapping in getSquareFromTouch currently uses the full
bounding rect (boardRef and getBoundingClientRect) which includes the
container's 1% padding, causing incorrect col/row math; fix it by computing the
inner content box before mapping: read the board element's computed padding
(e.g., via getComputedStyle on boardRef.current) or calculate padding pixels
from rect.width/height and the known 1% value, subtract left/right and
top/bottom padding to build an innerRect (innerLeft, innerTop, innerWidth,
innerHeight), then compute x = touch.clientX - innerLeft and y = touch.clientY -
innerTop and derive col/row as Math.floor((x / innerWidth) * 8) and
Math.floor((y / innerHeight) * 8), returning null when outside 0–7; update
getSquareFromTouch to use this inner content rect.

Comment on lines +307 to +320
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStartSquare.current) return;
const sq = getSquareFromTouch(e.changedTouches[0]);
if (sq) {
const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number);
attemptMove(srcRow, srcCol, sq[0], sq[1]);
}
touchStartSquare.current = null;
setHoveredSquare(null);
setSelectedSquare(null);
},
[getSquareFromTouch, attemptMove],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd ChessboardComponent.tsx

Repository: NOVUS-X/XLMate

Length of output: 108


🏁 Script executed:

cat -n frontend/components/chess/ChessboardComponent.tsx | head -450 | tail -250

Repository: NOVUS-X/XLMate

Length of output: 9930


🏁 Script executed:

cat -n frontend/components/chess/ChessboardComponent.tsx | sed -n '220,330p'

Repository: NOVUS-X/XLMate

Length of output: 4177


🏁 Script executed:

cat -n frontend/components/chess/ChessboardComponent.tsx | sed -n '1,100p'

Repository: NOVUS-X/XLMate

Length of output: 4301


🌐 Web query:

React TouchEvent synthetic click after touchend preventDefault mobile

💡 Result:

In React web apps on mobile browsers, a touch gesture (touchstart → optional touchmove → touchend) generates a synthetic click event ~300ms after touchend for compatibility. This can cause both onTouchEnd and onClick handlers to fire if both are attached to the same element. To prevent the synthetic click after handling touchend: 1. Call e.preventDefault in the onTouchEnd handler. This suppresses the subsequent click event across browsers. Example: function handleTouchEnd(e) { // Your touch logic here console.log('Touch ended'); e.preventDefault; // Prevents synthetic click } function handleClick(e) { // Only fires on real mouse clicks (desktop) console.log('Real click'); } return (

Tap/Click me
); Key points from sources: - Event order: touchstart → touchmove* → touchend → mousemove → mousedown → mouseup → click (sources 3,21). - preventDefault on touchend specifically cancels the click (sources 3,5,21). - React SyntheticEvent supports preventDefault identically across browsers (source 17). - Unlike touchstart (blocks scrolling, use only if needed), touchend preventDefault doesn't interfere with scrolling (source 4). - For touchmove preventDefault (e.g., custom drag), React's delegation makes it passive by default; use refs + addEventListener({passive: false}) (sources 2,24). Alternative approaches: - Use onTouchEnd + onMouseUp (fires one or the other; source 3). - CSS touch-action: manipulation (reduces 300ms click delay, no preventDefault needed; source 9). - Pointer events (onPointerDown/onPointerUp) for unified handling. This works reliably on iOS Safari, Chrome Android (2026). No official React docs page, but confirmed in React issues/GitHub and MDN.

Citations:


🌐 Web query:

HTML5 touch events synthetic click on target square after touchend

💡 Result:

To generate a synthetic click event on the target element (e.g., a square) immediately after the native touchend event in HTML5, use the touchend event handler to create and dispatch a MouseEvent of type "click" on event.target. This mimics the browser's natural behavior where browsers automatically dispatch emulated mouse/click events after touchend for single-touch interactions, but allows custom control (e.g., immediate firing without 300ms delay, or after custom logic). ## Code Example javascript element.addEventListener('touchend', function(event) { // Optional: Check for single touch, no significant movement, etc. if (event.changedTouches.length === 1) { const touch = event.changedTouches[0]; // Create synthetic click event using modern MouseEvent constructor const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: touch.clientX, clientY: touch.clientY, screenX: touch.screenX, screenY: touch.screenY, button: 0, buttons: 1 }); // Dispatch on the original target (the touched square) event.target.dispatchEvent(clickEvent); } }, { passive: false }); // passive: false to allow preventDefault if needed ## Key Points - Target: Use event.target from touchend (the square/element first touched). - Coordinates: Pull from event.changedTouches[0] for accurate position. - Why after touchend?: Matches browser timing; touchend fires when finger lifts, then click normally follows. - Prevent double-firing: If you want only your synthetic click (no browser's), call event.preventDefault in touchstart or touchend. - Browser behavior: Browsers auto-generate these synthetic clicks for compatibility (single touch → mousedown/mouseup/click). Your code replicates this manually. - Modern & Cross-browser: new MouseEvent works in all modern browsers (Chrome 41+, Firefox 45+, Safari 9+). For legacy, fallback to document.createEvent('MouseEvents') + initMouseEvent. This approach is commonly used to eliminate 300ms touch delay or implement custom tap handling on interactive elements like game squares/buttons.

Citations:


Suppress synthetic click after touchend to prevent re-selecting the moved piece.

After a successful touch drag-and-drop, the browser automatically dispatches a synthetic click event on the target element for touch-to-click compatibility. Without intervention, this click fires and handleSquareClick (line 230) sees an empty selectedSquare (cleared at line 317) and a piece now occupying the target square, causing it to select the just-moved piece. The destination square displays the selection ring immediately after the move.

Calling e.preventDefault() on the touchend event suppresses the synthetic click on all modern browsers.

Suggested fix
   const handleTouchEnd = useCallback(
     (e: React.TouchEvent) => {
       if (!touchStartSquare.current) return;
+      e.preventDefault();
       const sq = getSquareFromTouch(e.changedTouches[0]);
       if (sq) {
         const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number);
         attemptMove(srcRow, srcCol, sq[0], sq[1]);
       }
       touchStartSquare.current = null;
       setHoveredSquare(null);
       setSelectedSquare(null);
     },
     [getSquareFromTouch, attemptMove],
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStartSquare.current) return;
const sq = getSquareFromTouch(e.changedTouches[0]);
if (sq) {
const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number);
attemptMove(srcRow, srcCol, sq[0], sq[1]);
}
touchStartSquare.current = null;
setHoveredSquare(null);
setSelectedSquare(null);
},
[getSquareFromTouch, attemptMove],
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStartSquare.current) return;
e.preventDefault();
const sq = getSquareFromTouch(e.changedTouches[0]);
if (sq) {
const [srcRow, srcCol] = touchStartSquare.current.split(",").map(Number);
attemptMove(srcRow, srcCol, sq[0], sq[1]);
}
touchStartSquare.current = null;
setHoveredSquare(null);
setSelectedSquare(null);
},
[getSquareFromTouch, attemptMove],
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/chess/ChessboardComponent.tsx` around lines 307 - 320,
The touchend handler handleTouchEnd should call e.preventDefault() to suppress
the synthetic click that re-selects the moved piece: inside handleTouchEnd
(before/after computing sq) when a destination square is detected and you call
attemptMove, invoke e.preventDefault() so handleSquareClick does not receive a
synthetic click after the move; ensure touchStartSquare.current,
setSelectedSquare(null), and setHoveredSquare(null) logic remains unchanged so
state clearing still happens.

@gabito1451 gabito1451 merged commit 446ad27 into OpenKnight-Foundation:main Apr 28, 2026
2 checks passed
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.

Frontend: Responsive mobile board interface

2 participants