import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, } from "react"; import { allCandidates, classicRegions, createEmptyPuzzle, findConflicts, isSolved, normalizePuzzle, validatePuzzle, type NormalizedPuzzle, type PuzzleDefinition, } from "../domain"; import { decodePuzzleHash, fromDomainPuzzle, toDomainPuzzle, type SudokuDocument, } from "../formats"; import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples"; import type { DifficultyAssessment, ExactSolveResult, GeneratedVariantPuzzle, GenerateVariantOptions, LogicalSolveResult, } from "../solver"; import { createProjectLibrary, createProjectRecord, type ProjectLibraryMode, type SudokuProgress, type SudokuProjectRecord, type SudokuProjectSummary, } from "../storage"; import { createSession, enterSelection, eraseSelection, maskValues, restoreSnapshot, snapshotSession, valueForKey, type EntryMode, type PlaySession, type PlaySnapshot, } from "../state/session"; import { digitCompletions, matchingDigitCells, toggledDigitHighlight, } from "../state/gameplayHelpers"; import { createSolverWorkerClient, type SolverWorkerClient } from "../workers"; import { ConstraintEditor } from "./ConstraintEditor"; import { DigitCompletionBar } from "./DigitCompletionBar"; import { GeneratorWorkspace } from "./GeneratorWorkspace"; import { HelpersWorkspace } from "./HelpersWorkspace"; import { ImportExportDialog } from "./ImportExportDialog"; import { LibraryDialog } from "./LibraryDialog"; import { NumberPad } from "./NumberPad"; import { SolveWorkspace } from "./SolveWorkspace"; import { SudokuBoard } from "./SudokuBoard"; type Workspace = "play" | "set" | "generate" | "solve" | "helpers"; type FeedbackKind = "info" | "success" | "error"; interface Feedback { readonly kind: FeedbackKind; readonly message: string; } interface HistoryEntry { readonly puzzle: PuzzleDefinition; readonly session: PlaySnapshot; } const library = createProjectLibrary(); const MAX_HISTORY = 100; function valuesToMask(values: readonly number[] | undefined): number { return (values ?? []).reduce((mask, value) => mask | (1 << (value - 1)), 0); } function sessionFromDocument( puzzle: PuzzleDefinition, progress?: Pick< SudokuDocument, | "values" | "cornerMarks" | "centerMarks" | "candidates" | "colors" | "elapsedMs" >, ): PlaySession { const session = createSession(puzzle.givens); if (progress?.values?.length === puzzle.size * puzzle.size) { session.values = [...progress.values]; } if (progress?.cornerMarks?.length === puzzle.size * puzzle.size) { session.cornerMarks = progress.cornerMarks.map(valuesToMask); } const centerMarks = progress?.centerMarks ?? progress?.candidates; if (centerMarks?.length === puzzle.size * puzzle.size) { session.centerMarks = centerMarks.map(valuesToMask); } if (progress?.colors?.length === puzzle.size * puzzle.size) { session.colors = [...progress.colors]; } session.elapsedSeconds = Math.floor((progress?.elapsedMs ?? 0) / 1_000); return session; } function sessionFromProgress( puzzle: PuzzleDefinition, progress: SudokuProgress | undefined, ): PlaySession { const session = createSession(puzzle.givens); if (progress === undefined) return session; session.values = [...progress.values]; session.cornerMarks = (progress.cornerMarks ?? []).length ? progress.cornerMarks!.map(valuesToMask) : puzzle.givens.map(() => 0); session.centerMarks = (progress.centerMarks ?? progress.candidates ?? []) .length ? (progress.centerMarks ?? progress.candidates)!.map(valuesToMask) : puzzle.givens.map(() => 0); session.colors = progress.colors ? [...progress.colors] : puzzle.givens.map(() => 0); session.elapsedSeconds = Math.floor((progress.elapsedMs ?? 0) / 1_000); return session; } function progressFromSession( puzzle: PuzzleDefinition, session: PlaySession, completed: boolean, ): SudokuProgress { return { version: 1, values: [...session.values], cornerMarks: session.cornerMarks.map((mask) => maskValues(mask, puzzle.size), ), centerMarks: session.centerMarks.map((mask) => maskValues(mask, puzzle.size), ), colors: [...session.colors], elapsedMs: session.elapsedSeconds * 1_000, completed, }; } function safeNormalize(puzzle: PuzzleDefinition): { readonly puzzle: NormalizedPuzzle; readonly error?: string; } { try { return { puzzle: normalizePuzzle(puzzle) }; } catch (error) { const size = Number.isInteger(puzzle.size) ? puzzle.size : 9; const cellCount = size * size; const givens = puzzle.givens.length === cellCount ? [...puzzle.givens] : new Array(cellCount).fill(0); const regions = puzzle.regions?.length === cellCount ? [...puzzle.regions] : classicRegions(size); return { puzzle: { ...puzzle, size, givens, regions, constraints: puzzle.constraints ?? [], }, error: error instanceof Error ? error.message : "The puzzle definition is invalid.", }; } } function initialProject(): { readonly puzzle: PuzzleDefinition; readonly session: PlaySession; } { if (typeof location !== "undefined" && location.hash.includes("sudoku=")) { try { const document = decodePuzzleHash(location.href); const puzzle = normalizePuzzle(toDomainPuzzle(document)); return { puzzle, session: sessionFromDocument(puzzle, document) }; } catch { // An invalid hash is non-fatal; the import dialog can report details. } } const puzzle = normalizePuzzle(CLASSIC_SAMPLE); return { puzzle, session: createSession(puzzle.givens) }; } function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3_600); const minutes = Math.floor((seconds % 3_600) / 60); const rest = seconds % 60; return [hours, minutes, rest] .map((part) => String(part).padStart(2, "0")) .join(":"); } function cellLabel(cell: number, size: number): string { return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`; } function cloneSample(sample: PuzzleDefinition): PuzzleDefinition { return normalizePuzzle(sample); } function downloadJson(filename: string, value: unknown): void { const url = URL.createObjectURL( new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json", }), ); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The operation failed."; } function constraintLabel(type: string): string { if (type === "xv") return "Sum pairs (5/10)"; return type .split("-") .map((part) => part[0]?.toUpperCase() + part.slice(1)) .join(" "); } export function Workbench() { const boot = useMemo(() => initialProject(), []); const [workspace, setWorkspace] = useState("play"); const [puzzle, setPuzzle] = useState(boot.puzzle); const [session, setSession] = useState(boot.session); const [selection, setSelection] = useState([0]); const [activeCell, setActiveCell] = useState(0); const [entryMode, setEntryMode] = useState("value"); const [showCandidates, setShowCandidates] = useState(false); const [showConflicts, setShowConflicts] = useState(true); const [showDigitCompletion, setShowDigitCompletion] = useState(true); const [enableDigitHighlight, setEnableDigitHighlight] = useState(true); const [highlightedDigit, setHighlightedDigit] = useState(null); const [past, setPast] = useState([]); const [future, setFuture] = useState([]); const [busy, setBusy] = useState(false); const [feedback, setFeedback] = useState(); const [logical, setLogical] = useState(); const [exact, setExact] = useState(); const [difficulty, setDifficulty] = useState(); const [generation, setGeneration] = useState(); const [solveError, setSolveError] = useState(); const [importOpen, setImportOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false); const [summaries, setSummaries] = useState( [], ); const [libraryMode, setLibraryMode] = useState("indexeddb"); const [libraryBusy, setLibraryBusy] = useState(false); const [libraryFeedback, setLibraryFeedback] = useState(); const [currentProjectId, setCurrentProjectId] = useState(); const workerRef = useRef(null); const draggingRef = useRef(false); const workbenchRef = useRef(null); const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]); const selectedSet = useMemo(() => new Set(selection), [selection]); const completionData = useMemo( () => digitCompletions(puzzle.size, session.values), [puzzle.size, session.values], ); const highlightedCells = useMemo( () => highlightedDigit === null ? new Set() : new Set( matchingDigitCells(puzzle.size, session.values, highlightedDigit), ), [highlightedDigit, puzzle.size, session.values], ); const candidateMasks = useMemo(() => { if (normalized.error) return session.values.map(() => 0); try { return allCandidates(normalized.puzzle, session.values).map((values) => valuesToMask(values), ); } catch { return session.values.map(() => 0); } }, [normalized, session.values]); const conflictData = useMemo(() => { if (normalized.error) return []; try { return findConflicts(normalized.puzzle, session.values); } catch { return []; } }, [normalized, session.values]); const conflictCells = useMemo( () => showConflicts ? new Set(conflictData.flatMap((conflict) => conflict.cells)) : new Set(), [conflictData, showConflicts], ); const solved = useMemo(() => { if (normalized.error) return false; try { return isSolved(normalized.puzzle, session.values); } catch { return false; } }, [normalized, session.values]); const clearAnalysis = useCallback(() => { setLogical(undefined); setExact(undefined); setDifficulty(undefined); setGeneration(undefined); setSolveError(undefined); }, []); const currentHistory = useCallback( (): HistoryEntry => ({ puzzle, session: snapshotSession(session) }), [puzzle, session], ); const commit = useCallback( (nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => { setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setFuture([]); setPuzzle(nextPuzzle); setSession(nextSession); clearAnalysis(); setFeedback(undefined); }, [clearAnalysis, currentHistory], ); const loadPuzzle = useCallback( ( nextPuzzle: PuzzleDefinition, progress?: Pick< SudokuDocument, | "values" | "cornerMarks" | "centerMarks" | "candidates" | "colors" | "elapsedMs" >, projectId?: string, ): void => { const valid = normalizePuzzle(nextPuzzle); setPuzzle(valid); setSession(sessionFromDocument(valid, progress)); setSelection([0]); setActiveCell(0); setHighlightedDigit(null); setPast([]); setFuture([]); setCurrentProjectId(projectId); clearAnalysis(); setFeedback({ kind: "success", message: "Puzzle loaded locally." }); }, [clearAnalysis], ); const restoreProject = useCallback( (record: SudokuProjectRecord): void => { const valid = normalizePuzzle(toDomainPuzzle(record.puzzle)); setPuzzle(valid); setSession(sessionFromProgress(valid, record.progress)); setSelection([0]); setActiveCell(0); setHighlightedDigit(null); setPast([]); setFuture([]); setCurrentProjectId(record.id); clearAnalysis(); setFeedback({ kind: "success", message: `Opened “${record.title || "Untitled puzzle"}”.`, }); }, [clearAnalysis], ); const refreshLibrary = useCallback(async (): Promise => { setLibraryMode(await library.ready()); setSummaries(await library.list()); }, []); useEffect(() => { workerRef.current = createSolverWorkerClient(); return () => { workerRef.current?.terminate(); workerRef.current = null; }; }, []); useEffect(() => { const stopDragging = () => { draggingRef.current = false; }; window.addEventListener("pointerup", stopDragging); window.addEventListener("pointercancel", stopDragging); return () => { window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointercancel", stopDragging); }; }, []); useEffect(() => { if (workspace !== "play" || session.paused || solved) return; const timer = window.setInterval( () => setSession((current) => ({ ...current, elapsedSeconds: current.elapsedSeconds + 1, })), 1_000, ); return () => window.clearInterval(timer); }, [session.paused, solved, workspace]); const undo = useCallback(() => { const target = past.at(-1); if (target === undefined) return; setFuture((entries) => [currentHistory(), ...entries].slice(0, MAX_HISTORY), ); setPast(past.slice(0, -1)); setPuzzle(target.puzzle); setSession((current) => restoreSnapshot(current, target.session)); clearAnalysis(); }, [clearAnalysis, currentHistory, past]); const redo = useCallback(() => { const target = future[0]; if (target === undefined) return; setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setFuture(future.slice(1)); setPuzzle(target.puzzle); setSession((current) => restoreSnapshot(current, target.session)); clearAnalysis(); }, [clearAnalysis, currentHistory, future]); const changeSelection = useCallback( (cell: number, additive: boolean, toggle: boolean): void => { setActiveCell(cell); setSelection((current) => { if (!additive) return [cell]; if (toggle && current.includes(cell)) { const remaining = current.filter((item) => item !== cell); return remaining.length ? remaining : [cell]; } return current.includes(cell) ? current : [...current, cell]; }); }, [], ); const handlePointerDown = useCallback( (cell: number, event: PointerEvent) => { if (session.paused) return; event.preventDefault(); event.currentTarget.focus(); const command = event.ctrlKey || event.metaKey; if ( workspace === "play" && enableDigitHighlight && command && session.values[cell] ) { draggingRef.current = false; changeSelection(cell, false, false); setHighlightedDigit((current) => toggledDigitHighlight(puzzle.size, session.values, cell, current), ); return; } if (!command) setHighlightedDigit(null); draggingRef.current = true; const additive = event.shiftKey || command; changeSelection(cell, additive, command); }, [ changeSelection, enableDigitHighlight, puzzle.size, session.paused, session.values, workspace, ], ); const handlePointerEnter = useCallback( (cell: number, event: PointerEvent) => { if (session.paused || !draggingRef.current || (event.buttons & 1) === 0) return; changeSelection(cell, true, false); }, [changeSelection, session.paused], ); const enterValue = useCallback( (value: number): void => { if (session.paused) return; if (workspace === "set" && entryMode === "value") { const givens = [...puzzle.givens]; const values = [...session.values]; const cornerMarks = [...session.cornerMarks]; const centerMarks = [...session.centerMarks]; for (const cell of selectedSet) { const next = givens[cell] === value ? 0 : value; givens[cell] = next; values[cell] = next; cornerMarks[cell] = 0; centerMarks[cell] = 0; } commit( { ...puzzle, givens }, { ...session, values, cornerMarks, centerMarks }, ); return; } commit( puzzle, enterSelection(session, selectedSet, entryMode, value, puzzle.givens), ); }, [commit, entryMode, puzzle, selectedSet, session, workspace], ); const erase = useCallback((): void => { if (session.paused) return; if (workspace === "set" && entryMode === "value") { const givens = [...puzzle.givens]; const values = [...session.values]; for (const cell of selectedSet) { givens[cell] = 0; values[cell] = 0; } commit({ ...puzzle, givens }, { ...session, values }); return; } commit( puzzle, eraseSelection(session, selectedSet, entryMode, puzzle.givens), ); }, [commit, entryMode, puzzle, selectedSet, session, workspace]); const moveActive = useCallback( (deltaRow: number, deltaColumn: number, extend: boolean): void => { const row = Math.floor(activeCell / puzzle.size); const column = activeCell % puzzle.size; const nextRow = Math.max(0, Math.min(puzzle.size - 1, row + deltaRow)); const nextColumn = Math.max( 0, Math.min(puzzle.size - 1, column + deltaColumn), ); const next = nextRow * puzzle.size + nextColumn; changeSelection(next, extend, false); requestAnimationFrame(() => { workbenchRef.current ?.querySelector(`[data-cell="${String(next)}"]`) ?.focus(); }); }, [activeCell, changeSelection, puzzle.size], ); const handleKeyDown = useCallback( (event: KeyboardEvent): void => { const command = event.ctrlKey || event.metaKey; if (command && event.key.toLowerCase() === "z") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); return; } if (command && event.key.toLowerCase() === "y") { event.preventDefault(); redo(); return; } if (command && event.key.toLowerCase() === "a") { event.preventDefault(); const all = Array.from( { length: puzzle.size * puzzle.size }, (_, cell) => cell, ); setSelection( event.shiftKey ? all.filter((cell) => !selectedSet.has(cell)) : all, ); return; } const movement: Record = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], }; const delta = movement[event.key]; if (delta !== undefined) { event.preventDefault(); moveActive(delta[0], delta[1], event.shiftKey); return; } if ( event.key === "Backspace" || event.key === "Delete" || event.key === "0" ) { event.preventDefault(); erase(); return; } if (!command) { const modes: Record = { z: "value", x: "corner", c: "center", v: "color", }; const nextMode = modes[event.key.toLowerCase()]; if (nextMode !== undefined) { event.preventDefault(); setEntryMode(nextMode); return; } const value = valueForKey(event.key, puzzle.size); if (value !== null) { event.preventDefault(); enterValue(value); } } }, [enterValue, erase, moveActive, puzzle.size, redo, selectedSet, undo], ); const runLogical = useCallback(async () => { if (normalized.error || workerRef.current === null) { setSolveError(normalized.error ?? "Solver worker is not ready yet."); return; } setBusy(true); setSolveError(undefined); try { const result = (await workerRef.current.request( { kind: "logical", puzzle: normalized.puzzle, options: { values: session.values }, }, { timeoutMs: 60_000 }, )) as LogicalSolveResult; setLogical(result); } catch (error) { setSolveError(errorMessage(error)); } finally { setBusy(false); } }, [normalized, session.values]); const runExact = useCallback(async () => { if (normalized.error || workerRef.current === null) { setSolveError(normalized.error ?? "Solver worker is not ready yet."); return; } setBusy(true); setSolveError(undefined); try { const result = (await workerRef.current.request( { kind: "solve", puzzle: normalized.puzzle, options: { values: session.values, maxSolutions: 2, maxNodes: 5_000_000, timeoutMs: 30_000, }, }, { timeoutMs: 40_000 }, )) as ExactSolveResult; setExact(result); } catch (error) { setSolveError(errorMessage(error)); } finally { setBusy(false); } }, [normalized, session.values]); const requestHint = useCallback(async () => { if (normalized.error || workerRef.current === null) { setFeedback({ kind: "error", message: normalized.error ?? "Solver worker is not ready yet.", }); return; } setBusy(true); try { const result = (await workerRef.current.request( { kind: "logical", puzzle: normalized.puzzle, options: { values: session.values, maxSteps: 1 }, }, { timeoutMs: 15_000 }, )) as LogicalSolveResult; const step = result.steps[0]; if (step === undefined) { setFeedback({ kind: result.status === "solved" ? "success" : "info", message: result.status === "solved" ? "The grid is already solved." : "No supported logical next step was found.", }); } else { if (step.focusCells.length) { setSelection([...step.focusCells]); setActiveCell(step.focusCells[0]!); } setFeedback({ kind: "info", message: step.explanation }); } } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [normalized, session.values]); const checkDefinition = useCallback(async () => { const validation = validatePuzzle(puzzle); if (!validation.valid) { setFeedback({ kind: "error", message: validation.issues .slice(0, 4) .map((issue) => `${issue.path}: ${issue.message}`) .join(" · "), }); return; } if (workerRef.current === null) return; setBusy(true); try { const result = (await workerRef.current.request( { kind: "solve", puzzle, options: { maxSolutions: 2, maxNodes: 5_000_000, timeoutMs: 30_000, }, }, { timeoutMs: 40_000 }, )) as ExactSolveResult; setFeedback({ kind: result.count === 1 && !result.truncated ? "success" : result.count === 0 || result.count >= 2 ? "error" : "info", message: result.count === 0 ? "The definition has no solution." : result.count >= 2 ? "The definition has multiple solutions." : result.truncated ? "A solution was found, but uniqueness was not established before a safety limit." : `The definition is valid and uniquely solvable (${result.nodes.toLocaleString()} search nodes).`, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [puzzle]); const generateConfigured = useCallback( async (options: GenerateVariantOptions) => { if (workerRef.current === null) return; setBusy(true); setFeedback({ kind: "info", message: "Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…", }); try { const generated = (await workerRef.current.request( { kind: "generate-variant", options }, { timeoutMs: 240_000 }, )) as GeneratedVariantPuzzle; loadPuzzle(generated.puzzle); setGeneration(generated); setDifficulty(generated.difficulty); setWorkspace("generate"); setFeedback({ kind: "success", message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [loadPuzzle], ); const rateCurrentPuzzle = useCallback(async () => { if (workerRef.current === null) return; if (normalized.error) { setFeedback({ kind: "error", message: normalized.error }); return; } setBusy(true); setFeedback({ kind: "info", message: "Checking uniqueness and rating the current puzzle locally…", }); try { const assessment = (await workerRef.current.request( { kind: "difficulty", puzzle: normalized.puzzle }, { timeoutMs: 60_000 }, )) as DifficultyAssessment; setGeneration(undefined); setDifficulty(assessment); setFeedback({ kind: assessment.uniqueness === "unique" ? "success" : "info", message: assessment.summary, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [normalized]); const checkBoard = useCallback(() => { if (normalized.error) { setFeedback({ kind: "error", message: normalized.error }); } else if (conflictData.length > 0) { setFeedback({ kind: "error", message: conflictData[0]!.message }); } else if (solved) { setFeedback({ kind: "success", message: `Solved in ${formatTime(session.elapsedSeconds)}.`, }); } else { const remaining = session.values.filter((value) => value === 0).length; setFeedback({ kind: "info", message: `No conflicts found. ${String(remaining)} cell${remaining === 1 ? " remains" : "s remain"}.`, }); } }, [ conflictData, normalized.error, session.elapsedSeconds, session.values, solved, ]); const saveCurrent = useCallback(async () => { setLibraryBusy(true); setLibraryFeedback(undefined); try { const now = Date.now(); const document = fromDomainPuzzle(puzzle); const progress = progressFromSession(puzzle, session, solved); let record: SudokuProjectRecord; const existing = currentProjectId ? await library.get(currentProjectId) : undefined; if (existing !== undefined) { record = { ...existing, title: puzzle.title?.trim() || "Untitled puzzle", updatedAt: now, puzzle: document, progress, }; } else { record = createProjectRecord(document, { title: puzzle.title?.trim() || "Untitled puzzle", progress, now, }); } const saved = await library.put(record); setCurrentProjectId(saved.id); await refreshLibrary(); setLibraryFeedback("Current puzzle and progress saved locally."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [currentProjectId, puzzle, refreshLibrary, session, solved]); const openProject = useCallback( async (id: string) => { setLibraryBusy(true); try { const record = await library.get(id); if (record === undefined) throw new Error("The saved puzzle no longer exists."); restoreProject(record); setLibraryOpen(false); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [restoreProject], ); const deleteProject = useCallback( async (id: string) => { if (!window.confirm("Delete this saved puzzle from this browser?")) return; setLibraryBusy(true); try { await library.delete(id); if (currentProjectId === id) setCurrentProjectId(undefined); await refreshLibrary(); setLibraryFeedback("Saved puzzle deleted."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [currentProjectId, refreshLibrary], ); const clearLibrary = useCallback(async () => { if ( !window.confirm( "Clear every saved Sudoku project from this browser? Export first if you need a backup.", ) ) return; setLibraryBusy(true); try { await library.clear(); setCurrentProjectId(undefined); await refreshLibrary(); setLibraryFeedback("Local library cleared."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary]); const exportLibrary = useCallback(async () => { setLibraryBusy(true); try { downloadJson("sudoku-tools-library.json", await library.exportAll()); setLibraryFeedback("Library backup downloaded."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, []); const importLibrary = useCallback( async (file: File) => { setLibraryBusy(true); try { if (file.size > 64 * 1_048_576) throw new Error("The library file exceeds the 64 MiB import limit."); const count = await library.importAll( JSON.parse(await file.text()) as unknown, ); await refreshLibrary(); setLibraryFeedback( `${String(count)} project${count === 1 ? "" : "s"} imported.`, ); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary], ); const applyValues = useCallback( (values: readonly number[]) => { commit(puzzle, { ...session, values: [...values] }); }, [commit, puzzle, session], ); const focusCells = useCallback((cells: readonly number[]) => { if (cells.length === 0) return; setSelection([...cells]); setActiveCell(cells[0]!); }, []); const activeConstraints = useMemo( () => [...new Set(normalized.puzzle.constraints.map((item) => item.type))], [normalized.puzzle.constraints], ); return (

Local puzzle studio

{puzzle.title || "Untitled Sudoku"}

{puzzle.author ? `Set by ${puzzle.author}. ` : ""} Set, play, explain and explore without uploading a puzzle.

{solved ? "Solved" : conflictData.length ? `${String(conflictData.length)} conflict${conflictData.length === 1 ? "" : "s"}` : "In progress"} {puzzle.size} × {puzzle.size} {puzzle.givens.filter(Boolean).length} given {puzzle.givens.filter(Boolean).length === 1 ? "" : "s"}
{feedback && (

{feedback.message}

)}
{selection.length === 1 ? cellLabel(selection[0]!, puzzle.size) : `${String(selection.length)} cells selected`}
{session.paused && ( )}
{workspace === "play" && showDigitCompletion && ( setHighlightedDigit((current) => current === digit ? null : digit, ) } /> )}

Rules

{puzzle.rules || `Place 1–${String(puzzle.size)} exactly once in every row, column and outlined region.`}

{activeConstraints.length > 0 && (
{activeConstraints.map((type) => ( {constraintLabel(type)} ))}
)}
setImportOpen(false)} onImport={(next, progress) => { try { loadPuzzle(next, progress); setWorkspace("play"); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }} /> setLibraryOpen(false)} onSave={() => void saveCurrent()} onOpen={(id) => void openProject(id)} onDelete={(id) => void deleteProject(id)} onClear={() => void clearLibrary()} onExport={() => void exportLibrary()} onImport={(file) => void importLibrary(file)} />
); }