fix(card-sorting): faithfully port UI/UX from Python original

Update Card Sorting Challenge to match the original Python implementation:

**Visual Changes:**
- Add status message display showing game state and instructions
- Update insert buttons to match Python styling:
  - Pill-shaped (border-radius: 20px)
  - Teal (#2c5f76) with 0.3 opacity when inactive
  - Blue (#1976d2) when card is selected
  - Smooth scale animation on hover
- Fix position slot sizing to 90px x 110px (matching original)
- Simplify slot content (remove position numbers)
- Add active state highlighting (blue glow) for empty slots when card selected
- Adjust SVG sizing in filled slots to 70px width

**Logic Fixes:**
- Fix INSERT_CARD array shift logic bug in Provider.tsx
  - Remove redundant else clause that could create oversized arrays
  - Cards that fall off end are properly collected during compaction
- Remove unused useEffect import

**Behavioral Changes:**
- Status messages now update based on game state:
  - Card selected: "Selected card with value X. Click a position or + button to place it."
  - All placed: "All cards placed! Click 'Check My Solution' to see how you did."
  - In progress: "X/Y cards placed. Select a card to continue."
- Empty slot click shows helpful message when no card selected

This brings the implementation in line with the original Python web_generator.py
design (lines 8662-9132) as requested.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-18 15:59:02 -05:00
parent 99751b39b2
commit c92076f232
5 changed files with 376 additions and 87 deletions

View File

@@ -3,7 +3,6 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
@@ -24,6 +23,7 @@ interface CardSortingContextValue {
// Actions
startGame: () => void
placeCard: (cardId: string, position: number) => void
insertCard: (cardId: string, insertPosition: number) => void
removeCard: (position: number) => void
checkSolution: () => void
revealNumbers: () => void
@@ -107,10 +107,18 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
// Simple insert logic (server will do proper compaction)
// Simple replacement (can leave gaps)
const newPlaced = [...state.placedCards]
const replacedCard = newPlaced[position]
newPlaced[position] = card
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Remove card from available
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// If slot was occupied, add replaced card back to available
if (replacedCard) {
newAvailable = [...newAvailable, replacedCard]
}
return {
...state,
@@ -119,6 +127,60 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
}
}
case 'INSERT_CARD': {
const { cardId, insertPosition } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
// Insert with shift and compact (no gaps)
const newPlaced = new Array(state.cardCount).fill(null)
// Copy existing cards, shifting those at/after insert position
for (let i = 0; i < state.placedCards.length; i++) {
if (state.placedCards[i] !== null) {
if (i < insertPosition) {
newPlaced[i] = state.placedCards[i]
} else {
// Cards at or after insert position shift right by 1
// Card will be collected during compaction if it falls off the end
newPlaced[i + 1] = state.placedCards[i]
}
}
}
// Place new card at insert position
newPlaced[insertPosition] = card
// Compact to remove gaps
const compacted: SortingCard[] = []
for (const c of newPlaced) {
if (c !== null) {
compacted.push(c)
}
}
// Fill final array (no gaps)
const finalPlaced = new Array(state.cardCount).fill(null)
for (let i = 0; i < Math.min(compacted.length, state.cardCount); i++) {
finalPlaced[i] = compacted[i]
}
// Remove from available
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Any excess cards go back to available
if (compacted.length > state.cardCount) {
const excess = compacted.slice(state.cardCount)
newAvailable = [...newAvailable, ...excess]
}
return {
...state,
availableCards: newAvailable,
placedCards: finalPlaced,
}
}
case 'REMOVE_CARD': {
const { position } = typedMove.data
const card = state.placedCards[position]
@@ -353,6 +415,23 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
[localPlayerId, sendMove, viewerId]
)
const insertCard = useCallback(
(cardId: string, insertPosition: number) => {
if (!localPlayerId) return
sendMove({
type: 'INSERT_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { cardId, insertPosition },
})
// Clear selection
setSelectedCardId(null)
},
[localPlayerId, sendMove, viewerId]
)
const removeCard = useCallback(
(position: number) => {
if (!localPlayerId) return
@@ -457,6 +536,7 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
// Actions
startGame,
placeCard,
insertCard,
removeCard,
checkSolution,
revealNumbers,

View File

@@ -5,7 +5,7 @@ import type {
} from '@/lib/arcade/validation/types'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
import { calculateScore } from './utils/scoringAlgorithm'
import { placeCardAtPosition, removeCardAtPosition } from './utils/validation'
import { placeCardAtPosition, insertCardAtPosition, removeCardAtPosition } from './utils/validation'
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
validateMove(
@@ -18,6 +18,8 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
return this.validateStartGame(state, move.data, move.playerId)
case 'PLACE_CARD':
return this.validatePlaceCard(state, move.data.cardId, move.data.position)
case 'INSERT_CARD':
return this.validateInsertCard(state, move.data.cardId, move.data.insertPosition)
case 'REMOVE_CARD':
return this.validateRemoveCard(state, move.data.position)
case 'REVEAL_NUMBERS':
@@ -113,16 +115,70 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
}
}
// Place the card using utility function
const { placedCards: newPlaced } = placeCardAtPosition(
// Place the card using utility function (simple replacement)
const { placedCards: newPlaced, replacedCard } = placeCardAtPosition(
state.placedCards,
card,
position,
position
)
// Remove card from available
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// If slot was occupied, add replaced card back to available
if (replacedCard) {
newAvailable = [...newAvailable, replacedCard]
}
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
private validateInsertCard(
state: CardSortingState,
cardId: string,
insertPosition: number
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only insert cards during playing phase' }
}
// Card must exist in availableCards
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) {
return { valid: false, error: 'Card not found in available cards' }
}
// Position must be valid (0 to cardCount, inclusive - can insert after last position)
if (insertPosition < 0 || insertPosition > state.cardCount) {
return {
valid: false,
error: `Invalid insert position: must be between 0 and ${state.cardCount}`,
}
}
// Insert the card using utility function (with shift and compact)
const { placedCards: newPlaced, excessCards } = insertCardAtPosition(
state.placedCards,
card,
insertPosition,
state.cardCount
)
// Remove from available
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Remove card from available
let newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Add any excess cards back to available (shouldn't normally happen)
if (excessCards.length > 0) {
newAvailable = [...newAvailable, ...excessCards]
}
return {
valid: true,

View File

@@ -2,6 +2,7 @@
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
import { useState, useEffect } from 'react'
export function PlayingPhase() {
const {
@@ -9,6 +10,7 @@ export function PlayingPhase() {
selectedCardId,
selectCard,
placeCard,
insertCard,
removeCard,
checkSolution,
revealNumbers,
@@ -18,6 +20,31 @@ export function PlayingPhase() {
elapsedTime,
} = useCardSorting()
// Status message (mimics Python updateSortingStatus)
const [statusMessage, setStatusMessage] = useState(
`Arrange the ${state.cardCount} cards in ascending order (smallest to largest)`
)
// Update status message based on state
useEffect(() => {
if (state.gamePhase !== 'playing') return
if (selectedCardId) {
const card = state.availableCards.find((c) => c.id === selectedCardId)
if (card) {
setStatusMessage(
`Selected card with value ${card.number}. Click a position or + button to place it.`
)
}
} else if (placedCount === state.cardCount) {
setStatusMessage('All cards placed! Click "Check My Solution" to see how you did.')
} else {
setStatusMessage(
`${placedCount}/${state.cardCount} cards placed. Select ${placedCount === 0 ? 'a' : 'another'} card to continue.`
)
}
}, [selectedCardId, placedCount, state.cardCount, state.gamePhase, state.availableCards])
// Format time display
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
@@ -32,6 +59,7 @@ export function PlayingPhase() {
return {
background: `hsl(220, 8%, ${lightness}%)`,
color: lightness > 60 ? '#2c3e50' : '#ffffff',
borderColor: lightness > 60 ? '#2c5f76' : 'rgba(255,255,255,0.4)',
}
}
@@ -45,16 +73,29 @@ export function PlayingPhase() {
const handleSlotClick = (position: number) => {
if (!selectedCardId) {
// No card selected - remove card if slot is occupied
// No card selected - if slot has a card, move it back and auto-select
if (state.placedCards[position]) {
const cardToMove = state.placedCards[position]!
removeCard(position)
// Auto-select the card that was moved back
selectCard(cardToMove.id)
} else {
setStatusMessage('Select a card first, or click a placed card to move it back.')
}
} else {
// Card is selected - place it
// Card is selected - place it (replaces existing card if any)
placeCard(selectedCardId, position)
}
}
const handleInsertClick = (insertPosition: number) => {
if (!selectedCardId) {
setStatusMessage('Please select a card first, then click where to insert it.')
return
}
insertCard(selectedCardId, insertPosition)
}
return (
<div
className={css({
@@ -64,6 +105,22 @@ export function PlayingPhase() {
height: '100%',
})}
>
{/* Status message */}
<div
className={css({
padding: '0.75rem 1rem',
background: '#e3f2fd',
borderLeft: '4px solid #2c5f76',
borderRadius: '0.25rem',
fontSize: 'sm',
fontWeight: '500',
color: '#2c3e50',
flexShrink: 0,
})}
>
{statusMessage}
</div>
{/* Header with timer and actions */}
<div
className={css({
@@ -264,7 +321,7 @@ export function PlayingPhase() {
</div>
</div>
{/* Position slots */}
{/* Position slots with insert buttons */}
<div className={css({ flex: 2, minWidth: '300px' })}>
<h3
className={css({
@@ -280,88 +337,154 @@ export function PlayingPhase() {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
gap: '0.25rem',
})}
>
{/* Insert button before first position */}
<button
type="button"
onClick={() => handleInsertClick(0)}
disabled={!selectedCardId}
className={css({
width: '32px',
height: '50px',
background: selectedCardId ? '#1976d2' : '#2c5f76',
color: 'white',
border: 'none',
borderRadius: '20px',
fontSize: '24px',
fontWeight: 'bold',
cursor: selectedCardId ? 'pointer' : 'default',
opacity: selectedCardId ? 1 : 0.3,
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
opacity: 1,
background: '#1976d2',
transform: selectedCardId ? 'scale(1.1)' : 'none',
},
})}
>
+
</button>
{/* Render each position slot followed by an insert button */}
{state.placedCards.map((card, index) => {
const gradientStyle = getSlotGradient(index, state.cardCount)
const isEmpty = card === null
return (
<div
key={index}
onClick={() => handleSlotClick(index)}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
border: '2px solid',
borderColor:
gradientStyle.color === '#ffffff' ? 'rgba(255,255,255,0.4)' : '#2c5f76',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '1rem',
minHeight: '80px',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
style={gradientStyle}
>
<div key={index}>
{/* Position slot */}
<div
onClick={() => handleSlotClick(index)}
className={css({
fontSize: 'sm',
fontWeight: 'bold',
opacity: 0.7,
width: '90px',
height: '110px',
padding: '0.5rem',
borderRadius: '8px',
border: '2px solid',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
position: 'relative',
_hover: {
transform: selectedCardId && isEmpty ? 'scale(1.05)' : 'none',
boxShadow:
selectedCardId && isEmpty ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
},
})}
style={
isEmpty
? {
...gradientStyle,
// Active state: add slight glow when card is selected
boxShadow: selectedCardId
? '0 0 0 2px #1976d2, 0 2px 8px rgba(25, 118, 210, 0.3)'
: 'none',
}
: {
background: '#fff',
color: '#333',
borderColor: '#2c5f76',
}
}
>
#{index + 1}
</div>
{card ? (
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
gap: '1rem',
})}
>
<div
dangerouslySetInnerHTML={{
__html: card.svgContent,
}}
className={css({
width: '120px',
'& svg': {
width: '100%',
height: 'auto',
},
})}
/>
{state.numbersRevealed && (
{card ? (
<>
<div
dangerouslySetInnerHTML={{
__html: card.svgContent,
}}
className={css({
width: '70px',
'& svg': {
width: '100%',
height: 'auto',
},
})}
/>
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
fontSize: 'xs',
opacity: 0.7,
fontStyle: 'italic',
textAlign: 'center',
})}
>
{card.number}
Click to move back
</div>
)}
</div>
) : (
<div
className={css({
flex: 1,
fontSize: 'sm',
opacity: 0.5,
fontStyle: 'italic',
})}
>
{selectedCardId ? 'Click to place card' : 'Empty'}
</div>
)}
</>
) : (
<div
className={css({
fontSize: 'sm',
fontStyle: 'italic',
textAlign: 'center',
})}
style={{ color: gradientStyle.color }}
>
{index === 0 ? 'Smallest' : index === state.cardCount - 1 ? 'Largest' : ''}
</div>
)}
</div>
{/* Insert button after this position */}
<button
type="button"
onClick={() => handleInsertClick(index + 1)}
disabled={!selectedCardId}
className={css({
width: '32px',
height: '50px',
background: selectedCardId ? '#1976d2' : '#2c5f76',
color: 'white',
border: 'none',
borderRadius: '20px',
fontSize: '24px',
fontWeight: 'bold',
cursor: selectedCardId ? 'pointer' : 'default',
opacity: selectedCardId ? 1 : 0.3,
transition: 'all 0.2s',
marginTop: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
opacity: 1,
background: '#1976d2',
transform: selectedCardId ? 'scale(1.1)' : 'none',
},
})}
>
+
</button>
</div>
)
})}

View File

@@ -119,6 +119,16 @@ export type CardSortingMove =
position: number // Which slot (0-indexed)
}
}
| {
type: 'INSERT_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string // Which card to insert
insertPosition: number // Where to insert (0-indexed, can be 0 to cardCount)
}
}
| {
type: 'REMOVE_CARD'
playerId: string

View File

@@ -1,13 +1,30 @@
import type { SortingCard } from '../types'
/**
* Place a card at a specific position, shifting existing cards
* Returns new placedCards array with no gaps
* Place a card at a specific position (simple replacement, can leave gaps)
* This is used when clicking directly on a slot
* Returns old card if slot was occupied
*/
export function placeCardAtPosition(
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
position: number,
position: number
): { placedCards: (SortingCard | null)[]; replacedCard: SortingCard | null } {
const newPlaced = [...placedCards]
const replacedCard = newPlaced[position]
newPlaced[position] = cardToPlace
return { placedCards: newPlaced, replacedCard }
}
/**
* Insert a card at a specific position, shifting existing cards and compacting
* This is used when clicking a + (insert) button
* Returns new placedCards array with no gaps
*/
export function insertCardAtPosition(
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
insertPosition: number,
totalSlots: number
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
// Create working array
@@ -16,20 +33,23 @@ export function placeCardAtPosition(
// Copy existing cards, shifting those at/after position
for (let i = 0; i < placedCards.length; i++) {
if (placedCards[i] !== null) {
if (i < position) {
if (i < insertPosition) {
// Before insert position - stays same
newPlaced[i] = placedCards[i]
} else {
// At or after position - shift right
if (i + 1 < totalSlots) {
newPlaced[i + 1] = placedCards[i]
} else {
// Card would fall off, will be handled by compaction
newPlaced[i + 1] = placedCards[i]
}
}
}
}
// Place new card
newPlaced[position] = cardToPlace
// Place new card at insert position
newPlaced[insertPosition] = cardToPlace
// Compact to remove gaps (shift all cards left)
const compacted: SortingCard[] = []
@@ -39,13 +59,13 @@ export function placeCardAtPosition(
}
}
// Fill final array
// Fill final array with compacted cards (no gaps)
const result = new Array(totalSlots).fill(null)
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
result[i] = compacted[i]
}
// Any excess cards are returned (shouldn't happen)
// Any excess cards are returned
const excess = compacted.slice(totalSlots)
return { placedCards: result, excessCards: excess }