Files
sudoku-tools/src/components/Workbench.tsx
T

1443 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number>(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<Workspace>("play");
const [puzzle, setPuzzle] = useState<PuzzleDefinition>(boot.puzzle);
const [session, setSession] = useState<PlaySession>(boot.session);
const [selection, setSelection] = useState<number[]>([0]);
const [activeCell, setActiveCell] = useState(0);
const [entryMode, setEntryMode] = useState<EntryMode>("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<number | null>(null);
const [past, setPast] = useState<HistoryEntry[]>([]);
const [future, setFuture] = useState<HistoryEntry[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>();
const [logical, setLogical] = useState<LogicalSolveResult>();
const [exact, setExact] = useState<ExactSolveResult>();
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
const [solveError, setSolveError] = useState<string>();
const [importOpen, setImportOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const [summaries, setSummaries] = useState<readonly SudokuProjectSummary[]>(
[],
);
const [libraryMode, setLibraryMode] =
useState<ProjectLibraryMode>("indexeddb");
const [libraryBusy, setLibraryBusy] = useState(false);
const [libraryFeedback, setLibraryFeedback] = useState<string>();
const [currentProjectId, setCurrentProjectId] = useState<string>();
const workerRef = useRef<SolverWorkerClient | null>(null);
const draggingRef = useRef(false);
const workbenchRef = useRef<HTMLElement>(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<number>()
: 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<number>(),
[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<void> => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>(`[data-cell="${String(next)}"]`)
?.focus();
});
},
[activeCell, changeSelection, puzzle.size],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): 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<string, readonly [number, number]> = {
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<string, EntryMode> = {
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 (
<main className="sudoku-workbench" ref={workbenchRef}>
<header className="workbench-hero">
<div className="hero-copy">
<p className="eyebrow">Local puzzle studio</p>
<h1>{puzzle.title || "Untitled Sudoku"}</h1>
<p>
{puzzle.author ? `Set by ${puzzle.author}. ` : ""}
Set, play, explain and explore without uploading a puzzle.
</p>
</div>
<div className="hero-actions" aria-label="Puzzle actions">
<button
type="button"
onClick={() => {
loadPuzzle(createEmptyPuzzle(9, { title: "Untitled Sudoku" }));
setWorkspace("set");
setEntryMode("value");
}}
>
New blank
</button>
<label className="select-wrap">
<span className="sr-only">Open built-in puzzle</span>
<select
defaultValue=""
onChange={(event) => {
const sample = SAMPLE_CATALOG.find(
({ id }) => id === event.target.value,
);
if (sample !== undefined) {
loadPuzzle(cloneSample(sample.puzzle));
setWorkspace("play");
}
event.target.value = "";
}}
>
<option value="" disabled>
Built-in examples
</option>
{SAMPLE_CATALOG.map((sample) => (
<option key={sample.id} value={sample.id}>
{sample.label}
</option>
))}
</select>
</label>
<button type="button" onClick={() => setImportOpen(true)}>
Import / export
</button>
<button
type="button"
onClick={() => {
setLibraryOpen(true);
void refreshLibrary();
}}
>
Library
</button>
<button type="button" onClick={() => window.print()}>
Print
</button>
</div>
</header>
<nav className="workspace-tabs" aria-label="Sudoku workspace">
{(
[
["play", "Play"],
["set", "Set"],
["generate", "Generate"],
["solve", "Solve"],
["helpers", "Helpers"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
className={workspace === id ? "is-active" : ""}
aria-current={workspace === id ? "page" : undefined}
onClick={() => {
setWorkspace(id);
if (id === "set") setEntryMode("value");
}}
>
{label}
</button>
))}
</nav>
<div className="workbench-status">
<div className="toolbar-group">
<span className={`status-pill${solved ? " status-solved" : ""}`}>
{solved
? "Solved"
: conflictData.length
? `${String(conflictData.length)} conflict${conflictData.length === 1 ? "" : "s"}`
: "In progress"}
</span>
<span>
{puzzle.size} × {puzzle.size}
</span>
<span>
{puzzle.givens.filter(Boolean).length} given
{puzzle.givens.filter(Boolean).length === 1 ? "" : "s"}
</span>
</div>
<time
className="timer-display"
dateTime={`PT${String(session.elapsedSeconds)}S`}
>
{formatTime(session.elapsedSeconds)}
</time>
</div>
{feedback && (
<p
className={`feedback-callout feedback-${feedback.kind}`}
role={feedback.kind === "error" ? "alert" : "status"}
>
{feedback.message}
</p>
)}
<div
className={`workbench-grid${workspace === "helpers" ? " workbench-grid--helpers" : ""}`}
>
<section className="board-column" aria-label="Puzzle board">
<div className="board-toolbar">
<div className="toolbar-group">
<button type="button" disabled={!past.length} onClick={undo}>
Undo
</button>
<button type="button" disabled={!future.length} onClick={redo}>
Redo
</button>
</div>
<span>
{selection.length === 1
? cellLabel(selection[0]!, puzzle.size)
: `${String(selection.length)} cells selected`}
</span>
</div>
<div className={`board-surface${session.paused ? " is-paused" : ""}`}>
<SudokuBoard
puzzle={normalized.puzzle}
values={session.values}
cornerMarks={session.cornerMarks}
centerMarks={session.centerMarks}
colors={session.colors}
candidates={candidateMasks}
selected={selectedSet}
highlighted={highlightedCells}
conflicts={conflictCells}
activeCell={activeCell}
showCandidates={showCandidates}
onCellPointerDown={handlePointerDown}
onCellPointerEnter={handlePointerEnter}
onKeyDown={handleKeyDown}
/>
{session.paused && (
<button
type="button"
className="paused-cover"
onClick={() =>
setSession((current) => ({ ...current, paused: false }))
}
>
<strong>Paused</strong>
<span>Resume puzzle</span>
</button>
)}
</div>
{workspace === "play" && showDigitCompletion && (
<DigitCompletionBar
size={puzzle.size}
completions={completionData}
highlightedDigit={highlightedDigit}
highlightingEnabled={enableDigitHighlight}
onHighlight={(digit) =>
setHighlightedDigit((current) =>
current === digit ? null : digit,
)
}
/>
)}
<div className="board-meta">
<section className="rule-card">
<p className="eyebrow">Rules</p>
<p>
{puzzle.rules ||
`Place 1${String(puzzle.size)} exactly once in every row, column and outlined region.`}
</p>
{activeConstraints.length > 0 && (
<div
className="constraint-chips"
aria-label="Active constraints"
>
{activeConstraints.map((type) => (
<span key={type}>{constraintLabel(type)}</span>
))}
</div>
)}
</section>
</div>
</section>
<aside className="side-panel">
{workspace === "play" && (
<div className="play-panel stack">
<div>
<p className="eyebrow">Play locally</p>
<h2>Enter your solve</h2>
<p className="muted">
Select one or several cells, then use the keypad or keyboard.
</p>
</div>
<NumberPad
size={puzzle.size}
mode={entryMode}
onMode={setEntryMode}
onValue={enterValue}
onErase={erase}
/>
<section className="panel-section">
<div className="action-row">
<button type="button" disabled={busy} onClick={requestHint}>
Hint
</button>
<button type="button" onClick={checkBoard}>
Check
</button>
<button
type="button"
onClick={() =>
setSession((current) => ({
...current,
paused: !current.paused,
}))
}
>
{session.paused ? "Resume" : "Pause"}
</button>
</div>
<label className="option-row">
<input
type="checkbox"
checked={showCandidates}
onChange={(event) =>
setShowCandidates(event.target.checked)
}
/>
Show automatically calculated candidates
</label>
<label className="option-row">
<input
type="checkbox"
checked={showConflicts}
onChange={(event) => setShowConflicts(event.target.checked)}
/>
Highlight conflicts immediately
</label>
<label className="option-row">
<input
type="checkbox"
checked={showDigitCompletion}
onChange={(event) =>
setShowDigitCompletion(event.target.checked)
}
/>
Show digit completion bar
</label>
<label className="option-row">
<input
type="checkbox"
checked={enableDigitHighlight}
onChange={(event) => {
const enabled = event.target.checked;
setEnableDigitHighlight(enabled);
if (!enabled) setHighlightedDigit(null);
}}
/>
Enable matching-digit highlighting (Ctrl/-click or bar)
</label>
</section>
<section className="panel-section">
<button
type="button"
className="danger"
onClick={() => commit(puzzle, createSession(puzzle.givens))}
>
Restart puzzle
</button>
</section>
<p className="muted shortcut-note">
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/ Z
undoes · Ctrl/-click a placed digit highlights its matches.
</p>
</div>
)}
{workspace === "set" && (
<div className="setter-workspace stack">
<section className="panel-section">
<p className="eyebrow">Grid clues</p>
<h2>Enter givens</h2>
<p className="muted">
In Value mode, digits become fixed clues. Notes and colours
remain play-test annotations.
</p>
<NumberPad
size={puzzle.size}
mode={entryMode}
onMode={setEntryMode}
onValue={enterValue}
onErase={erase}
/>
</section>
<ConstraintEditor
puzzle={puzzle}
selection={selection}
busy={busy}
onChange={(next) => commit(next, session)}
onNewGrid={(size) =>
loadPuzzle(
createEmptyPuzzle(size, {
title: puzzle.title ?? "Untitled Sudoku",
}),
)
}
onCheck={() => void checkDefinition()}
onGenerate={() => setWorkspace("generate")}
/>
</div>
)}
{workspace === "generate" && (
<GeneratorWorkspace
busy={busy}
assessment={difficulty}
generation={generation}
onGenerate={(options) => void generateConfigured(options)}
onRate={() => void rateCurrentPuzzle()}
/>
)}
{workspace === "solve" && (
<SolveWorkspace
size={puzzle.size}
busy={busy}
logical={logical}
exact={exact}
error={solveError}
onLogical={() => void runLogical()}
onExact={() => void runExact()}
onApplyValues={applyValues}
onFocusCells={focusCells}
/>
)}
{workspace === "helpers" && (
<HelpersWorkspace
size={puzzle.size}
selectedCells={selection}
candidateMasks={candidateMasks}
/>
)}
</aside>
</div>
<ImportExportDialog
open={importOpen}
puzzle={puzzle}
session={session}
onClose={() => setImportOpen(false)}
onImport={(next, progress) => {
try {
loadPuzzle(next, progress);
setWorkspace("play");
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
}}
/>
<LibraryDialog
open={libraryOpen}
summaries={summaries}
mode={libraryMode}
busy={libraryBusy}
feedback={libraryFeedback}
onClose={() => 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)}
/>
</main>
);
}