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

View File

@ -14,6 +14,7 @@ function App() {
numberCounts, numberCounts,
newGame, newGame,
selectCell, selectCell,
deselect,
setNumber, setNumber,
clearCell, clearCell,
toggleNotes, toggleNotes,
@ -25,13 +26,32 @@ function App() {
} = useGame() } = useGame()
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [hue, setHue] = useState(() => {
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
return isNaN(saved) ? 211 : saved
})
useEffect(() => { useEffect(() => {
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa' document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
}, [state.darkMode]) }, [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 ( return (
<div className={`app${state.darkMode ? ' dark' : ''}`}> <div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
<header className="app-header"> <header className="app-header">
<h1>Sudoku</h1> <h1>Sudoku</h1>
</header> </header>
@ -50,14 +70,17 @@ function App() {
solution={state.solution} solution={state.solution}
notes={state.notes} notes={state.notes}
selected={state.selected} selected={state.selected}
highlightedNumber={state.highlightedNumber}
showErrors={state.showErrors} showErrors={state.showErrors}
celebrating={state.showEffects ? state.celebrating : new Set()} celebrating={state.showEffects ? state.celebrating : new Set()}
selectCell={selectCell} selectCell={selectCell}
deselect={deselect}
/> />
<NumberPad <NumberPad
onNumber={setNumber} onNumber={setNumber}
numberCounts={numberCounts} numberCounts={numberCounts}
highlightedNumber={state.selected ? null : state.highlightedNumber}
/> />
<GameControls <GameControls

View File

@ -2,8 +2,9 @@ import React from 'react'
import Cell from './Cell' import Cell from './Cell'
import { checkValue } from '../utils/sudoku' 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 selectedValue = selected && board[selected.row][selected.col]
const activeNumber = selected ? selectedValue : highlightedNumber
return ( return (
<div className="board"> <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) Math.floor(selected.col / 3) === Math.floor(c / 3)
const isSameNumber = const isSameNumber =
!isSelected && !isSelected &&
selectedValue !== 0 &&
value !== 0 && value !== 0 &&
value === selectedValue activeNumber !== null &&
activeNumber !== 0 &&
value === activeNumber
const isError = const isError =
showErrors && showErrors &&
!isGiven && !isGiven &&
@ -43,7 +45,7 @@ function Board({ board, puzzle, solution, notes, selected, showErrors, celebrati
isSameNumber={isSameNumber} isSameNumber={isSameNumber}
isError={isError} isError={isError}
isCelebrating={isCelebrating} 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' import React from 'react'
function NumberPad({ onNumber, numberCounts }) { function NumberPad({ onNumber, numberCounts, highlightedNumber }) {
return ( return (
<div className="number-pad"> <div className="number-pad">
<div className="number-buttons"> <div className="number-buttons">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => { {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const remaining = 9 - numberCounts[n] const remaining = 9 - numberCounts[n]
const disabled = remaining <= 0 const disabled = remaining <= 0
const isHighlighted = highlightedNumber === n
return ( return (
<button <button
key={n} key={n}
className={`num-btn${disabled ? ' disabled' : ''}`} className={`num-btn${disabled ? ' disabled' : ''}${isHighlighted ? ' highlighted' : ''}`}
onClick={() => !disabled && onNumber(n)} onClick={() => onNumber(n)}
disabled={disabled} disabled={disabled}
> >
<span className="num-btn-value">{n}</span> <span className="num-btn-value">{n}</span>

View File

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