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-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/App.jsx
25
src/App.jsx
@ -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
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user