feat: implement edit mode state machine (i and Escape)

Added mode state machine with visual feedback:

Mode State:
- CursorMode type: 'normal' | 'edit'
- Tracked in useCursor with useState
- Starts in 'normal' mode

Key Bindings:
- Press 'i' in normal mode → Enter edit mode
- Press 'Escape' in edit mode → Return to normal mode
- All vim motions (hjkl, anchors, etc.) only work in normal mode
- Edit mode blocks all movement keys

Visual Feedback:
- Normal mode: Blue outline (rgb(59, 130, 246))
- Edit mode: Green pulsing outline (rgb(34, 197, 94))
- Smooth pulse animation (1.5s ease-in-out infinite)
- cursor-edit CSS class applied in edit mode

Flow:
1. Game starts in normal mode (blue cursor)
2. Press 'i' → Green pulsing cursor (edit mode)
3. Press 'Escape' → Blue cursor (normal mode)

No digit entry yet - just state machine and visual indicators.
Phase 40.3.1 complete.
This commit is contained in:
2025-10-22 19:22:15 +01:00
parent 8ac008aeac
commit 0ce1d7fe65
6 changed files with 49 additions and 9 deletions
+17 -1
View File
@@ -255,7 +255,7 @@
}
}
/* Cursor highlight */
/* Cursor highlight - Normal mode */
.sudoku-cell.cursor {
background-color: rgba(59, 130, 246, 0.3);
outline: 3px solid rgb(59, 130, 246);
@@ -264,6 +264,22 @@
position: relative;
}
/* Cursor highlight - Edit mode */
.sudoku-cell.cursor-edit {
background-color: rgba(34, 197, 94, 0.3);
outline: 3px solid rgb(34, 197, 94);
animation: edit-pulse 1.5s ease-in-out infinite;
}
@keyframes edit-pulse {
0%, 100% {
outline-color: rgb(34, 197, 94);
}
50% {
outline-color: rgb(74, 222, 128);
}
}
/* Invalid move flash */
.sudoku-cell.invalid-move {
background-color: rgba(239, 68, 68, 0.4);
+1 -1
View File
@@ -1,2 +1,2 @@
export { useCursor } from './useCursor';
export type { CursorManager, Coord } from './types';
export type { CursorManager, Coord, CursorMode } from './types';
+3
View File
@@ -1,5 +1,7 @@
export type Coord = { row: number; col: number };
export type CursorMode = 'normal' | 'edit';
export type Motion = { dr: number; dc: number; steps: number };
export type VimMotionSystem = {
@@ -13,6 +15,7 @@ export type VimMotionSystem = {
export type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
+17 -2
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import type { Coord, CursorManager } from './types';
import type { Coord, CursorManager, CursorMode } from './types';
import type { GameStatusManager } from '../useGameStatus/types';
import { useVimMotions } from './useVimMotions';
export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject<HTMLDivElement | null>) {
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
const [mode, setMode] = useState<CursorMode>('normal');
const { gameStatus } = gameStatusManager;
const vimMotions = useVimMotions();
@@ -92,6 +93,19 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re
function handleKeyDown(e: KeyboardEvent) {
const key = e.key;
if (mode === 'edit') {
if (key === 'Escape') {
setMode('normal');
}
return;
}
if (key === 'i') {
setMode('edit');
return;
}
const result = vimMotions.processKey(key);
if (!result) return;
@@ -131,10 +145,11 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [gameStatus]);
}, [gameStatus, mode]);
const cursor: CursorManager = {
position: () => position,
mode: () => mode,
moveLeft,
moveRight,
moveUp,
+9 -4
View File
@@ -1,11 +1,12 @@
import { useEffect } from 'react';
import type { Coord } from './types';
import type { Coord, CursorMode } from './types';
import type { BoardManager } from '../useBoard/types';
import type { GameStatus } from '../useGameStatus/types';
export function useCursorRender(
containerRef: BoardManager['containerRef'],
position: Coord,
mode: CursorMode,
gameStatus: GameStatus,
boardRendered: number
) {
@@ -18,14 +19,18 @@ export function useCursorRender(
const cells = container.querySelectorAll('.sudoku-cell');
cells.forEach(cell => {
cell.classList.remove('cursor');
cell.classList.remove('cursor', 'cursor-edit');
});
const index = position.row * 9 + position.col;
const targetCell = cells[index];
if (targetCell) {
targetCell.classList.add('cursor');
if (mode === 'edit') {
targetCell.classList.add('cursor', 'cursor-edit');
} else {
targetCell.classList.add('cursor');
}
}
}, [position, containerRef, gameStatus, boardRendered]);
}, [position, mode, containerRef, gameStatus, boardRendered]);
}
+2 -1
View File
@@ -18,8 +18,9 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
const cursor = useCursor(gameStatusManager, containerRef);
const cursorPosition = cursor.position();
const cursorMode = cursor.mode();
useCursorRender(containerRef, cursorPosition, gameStatus, boardRendered);
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
useEffect(() => {
renderBoard();