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:
parent
5c07425d1d
commit
2243abbb22
66
src/App.css
66
src/App.css
@ -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;
|
||||
}
|
||||
|
||||
|
||||
25
src/App.jsx
25
src/App.jsx
@ -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
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user