Add number highlight, ESC/ctrl-click deselect, and color theming via spacebar

- Highlight all cells matching a pressed number when no cell is selected (toggle on repeat)
- ESC key and Ctrl+click deselect the active cell
- Spacebar cycles through a random hue that themes the entire UI via CSS custom property --hue
- Theme hue is persisted in localStorage across page reloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Duran 2026-02-17 22:08:17 +01:00
parent 5c07425d1d
commit 2243abbb22
5 changed files with 95 additions and 39 deletions

View File

@ -126,7 +126,7 @@ body {
font-size: clamp(20px, 5.5vw, 32px);
font-weight: 500;
line-height: 1;
color: #1976d2;
color: hsl(var(--hue), 69%, 48%);
}
.cell.given .cell-value {
@ -140,23 +140,23 @@ body {
/* Cell highlighting */
.cell.selected {
background: #bbdefb;
background: hsl(var(--hue), 89%, 86%);
}
.cell.highlighted-row,
.cell.highlighted-col,
.cell.highlighted-box {
background: #e8eef4;
background: hsl(var(--hue), 22%, 93%);
}
.cell.same-number {
background: #c8e6f9;
background: hsl(var(--hue), 84%, 88%);
}
.cell.same-number.highlighted-row,
.cell.same-number.highlighted-col,
.cell.same-number.highlighted-box {
background: #b3d9f5;
background: hsl(var(--hue), 81%, 83%);
}
.cell.error {
@ -278,7 +278,7 @@ body {
}
.num-btn:hover:not(:disabled) {
background: #e3f2fd;
background: hsl(var(--hue), 89%, 96%);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
@ -292,10 +292,15 @@ body {
cursor: default;
}
.num-btn.highlighted {
background: hsl(var(--hue), 89%, 86%);
box-shadow: 0 0 0 2px hsl(var(--hue), 69%, 48%), 0 2px 6px rgba(0, 0, 0, 0.12);
}
.num-btn-value {
font-size: clamp(16px, 4vw, 22px);
font-weight: 600;
color: #1976d2;
color: hsl(var(--hue), 69%, 48%);
line-height: 1;
}
@ -333,9 +338,9 @@ body {
}
.action-btn.active {
background: #1976d2;
background: hsl(var(--hue), 69%, 48%);
color: #fff;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3);
box-shadow: 0 2px 8px hsla(var(--hue), 69%, 48%, 0.3);
}
/* === GameControls === */
@ -370,8 +375,8 @@ body {
.difficulty-select:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
border-color: hsl(var(--hue), 69%, 48%);
box-shadow: 0 0 0 3px hsla(var(--hue), 69%, 48%, 0.15);
}
/* === Toggle Switch === */
@ -424,7 +429,7 @@ body {
}
.checkbox-label input:checked + .toggle-track {
background: #1976d2;
background: hsl(var(--hue), 69%, 48%);
}
.checkbox-label input:checked + .toggle-track::after {
@ -454,14 +459,14 @@ body {
.btn-primary {
flex: 1;
background: #1976d2;
background: hsl(var(--hue), 69%, 48%);
color: #fff;
box-shadow: 0 2px 6px rgba(25, 118, 210, 0.25);
box-shadow: 0 2px 6px hsla(var(--hue), 69%, 48%, 0.25);
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
box-shadow: 0 3px 8px rgba(25, 118, 210, 0.35);
background: hsl(var(--hue), 72%, 42%);
box-shadow: 0 3px 8px hsla(var(--hue), 69%, 48%, 0.35);
}
.btn-secondary {
@ -694,7 +699,7 @@ body {
}
.app.dark .cell-value {
color: #64b5f6;
color: hsl(var(--hue), 87%, 68%);
}
.app.dark .cell.given .cell-value {
@ -706,7 +711,7 @@ body {
}
.app.dark .cell.selected {
background: #1a3a5c;
background: hsl(var(--hue), 56%, 23%);
}
.app.dark .cell.highlighted-row,
@ -716,13 +721,13 @@ body {
}
.app.dark .cell.same-number {
background: #1a3050;
background: hsl(var(--hue), 50%, 21%);
}
.app.dark .cell.same-number.highlighted-row,
.app.dark .cell.same-number.highlighted-col,
.app.dark .cell.same-number.highlighted-box {
background: #1a2a45;
background: hsl(var(--hue), 45%, 18%);
}
.app.dark .cell.error {
@ -739,11 +744,16 @@ body {
}
.app.dark .num-btn:hover:not(:disabled) {
background: #1a3a5c;
background: hsl(var(--hue), 56%, 23%);
}
.app.dark .num-btn.highlighted {
background: hsl(var(--hue), 72%, 17%);
box-shadow: 0 0 0 2px hsl(var(--hue), 87%, 68%), 0 2px 6px rgba(0, 0, 0, 0.3);
}
.app.dark .num-btn-value {
color: #64b5f6;
color: hsl(var(--hue), 87%, 68%);
}
.app.dark .num-btn-count {
@ -761,12 +771,12 @@ body {
}
.app.dark .btn-primary {
background: #1565c0;
box-shadow: 0 2px 6px rgba(21, 101, 192, 0.4);
background: hsl(var(--hue), 72%, 42%);
box-shadow: 0 2px 6px hsla(var(--hue), 72%, 42%, 0.4);
}
.app.dark .btn-primary:hover:not(:disabled) {
background: #1976d2;
background: hsl(var(--hue), 69%, 48%);
}
.app.dark .difficulty-select {
@ -776,8 +786,8 @@ body {
}
.app.dark .difficulty-select:focus {
border-color: #64b5f6;
box-shadow: 0 0 0 3px rgba(100, 181, 246, 0.15);
border-color: hsl(var(--hue), 87%, 68%);
box-shadow: 0 0 0 3px hsla(var(--hue), 87%, 68%, 0.15);
}
.app.dark .action-btn {
@ -791,7 +801,7 @@ body {
}
.app.dark .action-btn.active {
background: #1565c0;
background: hsl(var(--hue), 72%, 42%);
color: #fff;
}

View File

@ -14,6 +14,7 @@ function App() {
numberCounts,
newGame,
selectCell,
deselect,
setNumber,
clearCell,
toggleNotes,
@ -25,13 +26,32 @@ function App() {
} = useGame()
const [showSettings, setShowSettings] = useState(false)
const [hue, setHue] = useState(() => {
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
return isNaN(saved) ? 211 : saved
})
useEffect(() => {
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
}, [state.darkMode])
useEffect(() => {
localStorage.setItem('sudoku-hue', hue)
}, [hue])
useEffect(() => {
function handleKeyDown(e) {
if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault()
setHue(Math.floor(Math.random() * 360))
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div className={`app${state.darkMode ? ' dark' : ''}`}>
<div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
<header className="app-header">
<h1>Sudoku</h1>
</header>
@ -50,14 +70,17 @@ function App() {
solution={state.solution}
notes={state.notes}
selected={state.selected}
highlightedNumber={state.highlightedNumber}
showErrors={state.showErrors}
celebrating={state.showEffects ? state.celebrating : new Set()}
selectCell={selectCell}
deselect={deselect}
/>
<NumberPad
onNumber={setNumber}
numberCounts={numberCounts}
highlightedNumber={state.selected ? null : state.highlightedNumber}
/>
<GameControls

View File

@ -2,8 +2,9 @@ import React from 'react'
import Cell from './Cell'
import { checkValue } from '../utils/sudoku'
function Board({ board, puzzle, solution, notes, selected, showErrors, celebrating, selectCell }) {
function Board({ board, puzzle, solution, notes, selected, highlightedNumber, showErrors, celebrating, selectCell, deselect }) {
const selectedValue = selected && board[selected.row][selected.col]
const activeNumber = selected ? selectedValue : highlightedNumber
return (
<div className="board">
@ -20,9 +21,10 @@ function Board({ board, puzzle, solution, notes, selected, showErrors, celebrati
Math.floor(selected.col / 3) === Math.floor(c / 3)
const isSameNumber =
!isSelected &&
selectedValue !== 0 &&
value !== 0 &&
value === selectedValue
activeNumber !== null &&
activeNumber !== 0 &&
value === activeNumber
const isError =
showErrors &&
!isGiven &&
@ -43,7 +45,7 @@ function Board({ board, puzzle, solution, notes, selected, showErrors, celebrati
isSameNumber={isSameNumber}
isError={isError}
isCelebrating={isCelebrating}
onClick={() => selectCell(r, c)}
onClick={(e) => e.ctrlKey || e.metaKey ? deselect() : selectCell(r, c)}
/>
)
})

View File

@ -1,17 +1,18 @@
import React from 'react'
function NumberPad({ onNumber, numberCounts }) {
function NumberPad({ onNumber, numberCounts, highlightedNumber }) {
return (
<div className="number-pad">
<div className="number-buttons">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const remaining = 9 - numberCounts[n]
const disabled = remaining <= 0
const isHighlighted = highlightedNumber === n
return (
<button
key={n}
className={`num-btn${disabled ? ' disabled' : ''}`}
onClick={() => !disabled && onNumber(n)}
className={`num-btn${disabled ? ' disabled' : ''}${isHighlighted ? ' highlighted' : ''}`}
onClick={() => onNumber(n)}
disabled={disabled}
>
<span className="num-btn-value">{n}</span>

View File

@ -56,6 +56,7 @@ function loadSavedState() {
...parsed,
notes: deserializeNotes(parsed.notes),
selected: null,
highlightedNumber: null,
showErrors: parsed.showErrors || false,
showEffects: parsed.showEffects !== undefined ? parsed.showEffects : true,
darkMode: parsed.darkMode || false,
@ -130,6 +131,7 @@ function createNewGameState(difficulty) {
board: cloneBoard(puzzle),
notes: createInitialNotes(),
selected: null,
highlightedNumber: null,
difficulty,
errors: 0,
timer: 0,
@ -165,11 +167,20 @@ function reducer(state, action) {
}
case 'SELECT_CELL': {
return { ...state, selected: { row: action.row, col: action.col } }
return { ...state, selected: { row: action.row, col: action.col }, highlightedNumber: null }
}
case 'DESELECT': {
return { ...state, selected: null }
}
case 'SET_NUMBER': {
if (!state.selected || state.isComplete) return state
if (state.isComplete) return state
if (!state.selected) {
const newHighlight = state.highlightedNumber === action.number ? null : action.number
return { ...state, highlightedNumber: newHighlight }
}
const { row, col } = state.selected
if (state.puzzle[row][col] !== 0) return state
@ -383,6 +394,10 @@ export function useGame() {
dispatch({ type: 'MOVE_SELECTION', dr, dc })
}, [])
const deselect = useCallback(() => {
dispatch({ type: 'DESELECT' })
}, [])
// Keyboard support
useEffect(() => {
function handleKeyDown(e) {
@ -393,6 +408,10 @@ export function useGame() {
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
deselect()
break
case 'Backspace':
case 'Delete':
e.preventDefault()
@ -436,7 +455,7 @@ export function useGame() {
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [setNumber, clearCell, moveSelection, toggleNotes, undo, redo])
}, [setNumber, clearCell, moveSelection, toggleNotes, undo, redo, deselect])
// Count how many of each number are placed
const numberCounts = Array(10).fill(0)
@ -453,6 +472,7 @@ export function useGame() {
numberCounts,
newGame,
selectCell,
deselect,
setNumber,
clearCell,
toggleNotes,