Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aeac54a
docs(ligretto): plan card drag and drop
memebattle-dev Aug 31, 2026
150796e
feat(ligretto): add card drag and drop
memebattle-dev Aug 31, 2026
662cf6f
fix(ligretto): exclude tests from shared build
memebattle-dev Aug 31, 2026
b488870
refactor(ligretto): address drag and drop review
memebattle-dev Sep 1, 2026
fbfab3c
refactor(ligretto): simplify card placement dnd
memebattle-dev Sep 1, 2026
6904535
fix(ligretto): [verified] address drag and drop review
memebattle-dev Sep 1, 2026
7b90271
refactor(ligretto): unify card interaction state
memebattle-dev Sep 1, 2026
dc0b9f7
fix(ligretto): [verified] target onboarding drop surfaces
memebattle-dev Sep 2, 2026
2ef5127
test(ligretto): [verified] place onboarding cards manually
memebattle-dev Sep 2, 2026
b8e50f8
refactor(ligretto): [verified] address card interaction review
memebattle-dev Sep 2, 2026
c3dce9d
refactor(ligretto): refine card interaction hooks
memebattle-dev Sep 2, 2026
ba5cfaa
[verified] refactor(ligretto): address card interaction review
memebattle-dev Sep 2, 2026
4743ab1
[verified] refactor(ligretto): keep card interaction mechanics in hooks
memebattle-dev Sep 5, 2026
32ddcbc
fix(ligretto): cancel native drag before card hotkeys
memebattle-dev Sep 5, 2026
7048d9b
fix(ligretto): ignore cancelled touch compatibility clicks
memebattle-dev Sep 5, 2026
e104ef0
refactor(ligretto): remove custom drag cancellation
memebattle-dev Sep 5, 2026
a1554e3
refactor(ligretto): [verified] address card interaction backlog
memebattle-dev Sep 9, 2026
3b5d4f4
fix(ligretto): [verified] preserve touch taps and unify cleanup
memebattle-dev Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions apps/ligretto-frontend/e2e/tests/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,24 @@ const performEvent = async (onboarding: OnboardingPage, event: OnboardingEvent)
return
case OnboardingEvent.PutStackCard:
await onboarding.getStackOpenDeckCard().click()
await expect(onboarding.getStackOpenDeckCard()).toHaveAttribute('data-card-focused', 'true')
await expect(onboarding.getStackOpenDeckCard()).toHaveAttribute('data-card-active', 'true')
await onboarding.getPlaygroundDeck(0).click()
return
case OnboardingEvent.PutFirstCard:
await onboarding.getRowCard(0).click()
await expect(onboarding.getRowCard(0)).toHaveAttribute('data-card-active', 'true')
await onboarding.getPlaygroundDeck(0).click()
return
case OnboardingEvent.PutSecondCard: {
const card = onboarding.getRowCard(1)
if ((await card.textContent())?.trim() === '1') {
await card.click()
return
}
await card.click()
await expect(card).toHaveAttribute('data-card-focused', 'true')
await expect(card).toHaveAttribute('data-card-active', 'true')
await onboarding.getPlaygroundDeck(0).click()
return
}
case OnboardingEvent.PutThirdCard:
await onboarding.getRowCard(2).click()
await expect(onboarding.getRowCard(2)).toHaveAttribute('data-card-focused', 'true')
await expect(onboarding.getRowCard(2)).toHaveAttribute('data-card-active', 'true')
await onboarding.getPlaygroundDeck(2).click()
return
case OnboardingEvent.PutLigretto:
Expand Down Expand Up @@ -117,6 +115,8 @@ test.describe('Onboarding', () => {

await test.step('opponent answers and the optional ligretto move fills the free slot', async () => {
await onboarding.getRowCard(1).click()
await expect(onboarding.getRowCard(1)).toHaveAttribute('data-card-active', 'true')
await onboarding.getPlaygroundDeck(0).click()
await expectStep(page, OnboardingStep.OpponentTurn)

await onboarding.getLigrettoDeckCard().click() // the second row slot is free — a ligretto card goes there
Expand All @@ -128,7 +128,7 @@ test.describe('Onboarding', () => {

await test.step('the green three and the final ligretto card end the round', async () => {
await onboarding.getRowCard(2).click()
await expect(onboarding.getRowCard(2)).toHaveAttribute('data-card-focused', 'true')
await expect(onboarding.getRowCard(2)).toHaveAttribute('data-card-active', 'true')
await expectStep(page, OnboardingStep.OpponentTurnSecondCard)

await onboarding.getPlaygroundDeck(0).click() // the wrong pile does not advance
Expand Down
1 change: 1 addition & 0 deletions apps/ligretto-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"e2e:start": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "catalog:",
"@floating-ui/react-dom": "^2.1.6",
"@fsmoothy/core": "^1.3.0",
"@memebattle/analytics": "workspace:^",
Expand Down
20 changes: 0 additions & 20 deletions apps/ligretto-frontend/src/ducks/game/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type { TypedStartListening } from '@reduxjs/toolkit'
import { TaskAbortError } from '@reduxjs/toolkit'
import {
PlayerStatus,
putCardAction,
putCardFromStackOpenDeck,
resumeGameEmitAction,
setPlayerStatusEmitAction,
startGameEmitAction,
Expand All @@ -21,8 +19,6 @@ import {
startGameAction,
togglePlayerStatusAction,
updateGameAction as updateGameSliceAction,
tapCardAction,
tapStackOpenDeckCardAction,
tapStackDeckCardAction,
tapLigrettoDeckCardAction,
resetGameStateAction,
Expand Down Expand Up @@ -144,20 +140,4 @@ export function addListeners(startListener: TypedStartListening<All>) {
listenerApi.dispatch(resumeGameEmitAction({ gameId }))
},
})

startListener({
actionCreator: tapCardAction,
effect: ({ payload }, listenerApi) => {
const gameId = gameIdSelector(listenerApi.getState())
listenerApi.dispatch(putCardAction({ cardIndex: payload.cardIndex, gameId }))
},
})

startListener({
actionCreator: tapStackOpenDeckCardAction,
effect: (_action, listenerApi) => {
const gameId = gameIdSelector(listenerApi.getState())
listenerApi.dispatch(putCardFromStackOpenDeck({ gameId }))
},
})
}
2 changes: 0 additions & 2 deletions apps/ligretto-frontend/src/ducks/game/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ export const initialState: GameState = {
export const togglePlayerStatusAction = createAction('@@game/TOGGLE_PLAYER_STATUS')
export const startGameAction = createAction('@@game/START_GAME')
export const resumeGameAction = createAction('@@game/RESUME_GAME')
export const tapCardAction = createAction<{ cardIndex: number }>('@@game/TapCardAction')
export const tapStackOpenDeckCardAction = createAction('@@game/TapStackOpenDeckCardAction')
export const tapStackDeckCardAction = createAction('@@game/TapStackDeckCardAction')
export const tapLigrettoDeckCardAction = createAction('@@game/TapLigrettoDeckCardAction')

Expand Down
8 changes: 7 additions & 1 deletion apps/ligretto-frontend/src/entities/card/ui/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ interface CardProps {
onClick?: () => void
/** Size of card **/
size?: CardSize
ref?: React.Ref<HTMLButtonElement>
style?: React.CSSProperties
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>
onPointerDown?: React.PointerEventHandler<HTMLButtonElement>
onTouchStart?: React.TouchEventHandler<HTMLButtonElement>
}

export const widthByCardSize: Record<CardSize, string> = {
Expand Down Expand Up @@ -153,7 +159,7 @@ export const Card: React.FC<CardProps> = ({
isHighlighted={isHighlighted}
disableRipple={isDisabled}
size={size}
onMouseDown={onClick}
onClick={onClick}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please verify before fixing: onMouseDownonClick is an unacknowledged trade-off, and with the 6 px MouseSensor constraint a jittery press is swallowed.

onMouseDown={onClick} was introduced deliberately in 8c6e400 (LIG-144, "put card on mousedown instead of click") as a speed-game affordance. This PR flips every Card to onClick without mentioning it in the description or README.

For draggable cards the combination with activationConstraint: { distance: 6 } has a concrete failure: mousedown on a row card, drift ≥ 6 px, mouseup over the card itself → dnd-kit activates, over === null, onDragEnd only dispatches dragTerminal, and the 50 ms capture-phase click suppression prevents onCardActivate. The card is neither selected nor placed; on the base branch it toggled on mousedown regardless of drift. For non-draggable cards (stack deck, ligretto pack, onboarding) activation now waits for mouseup and press-drag-off-release no longer fires.

If the change is intended, please say so in the PR body and the README. If not, options are a larger distance, or activating on onMouseDown for non-draggable cards only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

3b5d4f4 documents the current release-time activation, non-draggable latency and threshold-crossing cancelled placement in the README; the PR description is synchronized. LIG-144 deliberately used mousedown. Should we retain the current onClick trade-off, or restore mousedown activation for non-draggable cards? I left behavior unchanged rather than choose silently.

isDisabled={isDisabled}
isSelected={isSelected}
isHidden={isHidden}
Expand Down
2 changes: 0 additions & 2 deletions apps/ligretto-frontend/src/features/cardFocus/index.ts

This file was deleted.

This file was deleted.

This file was deleted.

48 changes: 0 additions & 48 deletions apps/ligretto-frontend/src/features/cardFocus/ui/useCardFocus.ts

This file was deleted.

54 changes: 54 additions & 0 deletions apps/ligretto-frontend/src/features/cardInteraction/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Card interaction ownership

`CardInteractionProvider` owns an explicit `idle | focused | dragging` reducer.
It uses standard dnd-kit mouse/touch sensors, outside-click dismissal and
central provider enablement. It does not read Redux, select cards, validate game
rules, dispatch game commands, or render a card.

Components opt into behavior through hooks:

- `useCardInteraction(target, identityDependencies)` supplies selection flags and
pointer toggling. Identity changes and unmount clear only that target. The
argument-free overload exposes the current target and explicit clearing.
- `useDraggableCard(target, card, disabled?)` attaches the native source to the
component's own element. Card/target identity changes, disablement and unmount
invalidate selection; components decide how to hide the original while dragging.
- `useDroppableTarget(target, onDrop)` attaches a semantic destination and invokes
its owner through dnd data only for a live, enabled source and destination. The owner validates current game rules and dispatches its own command.
- `useCardHotkey(key, callback)` clears selection and invokes the owner when the
provider is enabled, without changing the native gesture.
It has no options object. Clearing then toggling deliberately keeps a repeated
row/open-stack shortcut selected; pointer activation still toggles it off.
- `useCardDragTarget()` derives a live drag target from dnd-kit and its registered
source, independently of selection. `PlayerCardDragOverlay` uses it to look up current Redux data and
render the dnd-kit presentation primitive and game card outside this feature.

The target union and `getInteractionTargetKey` cover row, open-stack and playground
identities. Hooks return behavior/data, never styles or card wrapper elements.

## Gesture and command semantics

Gameplay shortcuts remain available during pending and active mouse/touch gestures.
They do not cancel native dragging or prevent a valid drop on release. Source
visibility and the overlay follow the native gesture, not hotkey selection.
There are no custom sensor adapters, Escape interception or release-click guards.
The standard dnd-kit `onDragCancel` lifecycle only synchronizes reducer state.
Disabled interactions and invalid or stale sources/destinations cannot dispatch drops.

Click placement deliberately keeps its selected source until a server-confirmed
card identity update. It continues to submit the explicit destination to backend
validation, whereas drag owners prevalidate against their current destination.
Do not clear click selection optimistically or auto-place value-1 source cards.

Onboarding keeps its dedicated playground, callback routing and outline refs.
Its source owners include step availability in their identity dependencies so a
selection cannot outlive the step that enabled it.

## Tests

Feature tests drive real dnd-kit mouse and touch events without a Redux provider; component
tests verify command payloads, overlay/source visibility, shortcut availability,
repeated selection, onboarding refs, stale/disabled drops and hotkeys during native gestures. The
native mouse sensor suppresses document clicks for 50 ms after release; tests that
mix drag and click cases must allow that teardown to finish. No new E2E harness is
required by this refinement.
Loading