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

2823 lines
90 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,
constraintLabel,
createEmptyPuzzle,
findConflicts,
isSolved,
normalizePuzzle,
validatePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
} from "../domain";
import {
decodePuzzleHash,
extractPreservedDocumentExtras,
fromDomainPuzzle,
normalizeSafeVisualPrimitives,
toDomainPuzzle,
type PreservedSudokuDocumentExtras,
type SudokuDocument,
} from "../formats";
import type { CandidateOverlay } from "../helpers";
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
import type {
DifficultyAssessment,
ExactSolveResult,
GeneratedVariantBatch,
GeneratedVariantPuzzle,
GenerateVariantBatchOptions,
GenerateVariantOptions,
LogicalStep,
LogicalSolveResult,
PuzzleQualityAnalysis,
PuzzleQualityOptions,
QualityItemReference,
} from "../solver";
import { qualityItemCells } 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 {
applyLogicalStepToSession,
autoRemoveNotesAfterPlacements,
fillLegalCenterCandidates,
pruneInvalidNotes,
} from "../state/candidateMaintenance";
import {
digitCompletions,
matchingDigitCells,
toggledDigitHighlight,
} from "../state/gameplayHelpers";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
setAidMemoireEnabled,
type AidMemoireState,
} from "../state/aidMemoire";
import { navigateGridCell } from "../state/gridNavigation";
import {
parseCandidateVerbosity,
type CandidateVerbosity,
} from "../state/uiPreferences";
import {
activeHypothesis,
aidMemoireFromGameplayState,
beginHypothesis,
createGameplayHistory,
createSavepoint,
deleteSavepoint,
describeGameplayChange,
finishHypothesis,
gameplayMoment,
parseGameplayHistory,
recordGameplayMoment,
restoreSavepoint,
serializeGameplayHistory,
sessionFromGameplayState,
type GameplayHistory,
} from "../state/playHistory";
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor";
import { AidMemoire } from "./AidMemoire";
import { BoardViewport } from "./BoardViewport";
import { DigitCompletionBar } from "./DigitCompletionBar";
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
import { GuidedHint } from "./GuidedHint";
import { foggedCellsForPuzzle } from "./fogVisibility";
import {
guidedHintOverlay,
guidedHintStepIsVisible,
nextGuidedHintStage,
type GuidedHintStage,
} from "./guidedHint";
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 {
SetterQualityLab,
type SetterQualityRunKind,
} from "./SetterQualityLab";
import { SudokuBoard } from "./SudokuBoard";
type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
type FeedbackKind = "info" | "success" | "error";
interface Feedback {
readonly kind: FeedbackKind;
readonly message: string;
}
type AutosaveStatus = "checking" | "idle" | "saving" | "saved" | "error";
interface HistoryEntry {
readonly puzzle: PuzzleDefinition;
readonly session: PlaySnapshot;
readonly aidMemoire: AidMemoireState;
readonly guidedCandidateTracking: boolean;
}
const library = createProjectLibrary();
const MAX_HISTORY = 100;
const AUTOSAVE_DRAFT_ID = "autosave-current";
const AUTOSAVE_DELAY_MS = 750;
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,
aidMemoire: AidMemoireState,
gameplayHistory?: GameplayHistory,
): 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,
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
...(gameplayHistory === undefined
? {}
: {
gameplayHistory: serializeGameplayHistory(
gameplayHistory,
puzzle.size,
),
}),
};
}
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;
readonly aidMemoire: AidMemoireState;
readonly preservedExtras?: PreservedSudokuDocumentExtras;
} {
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),
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
preservedExtras: extractPreservedDocumentExtras(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),
aidMemoire: createAidMemoire(puzzle.size),
};
}
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 useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia(query).matches,
);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const media = window.matchMedia(query);
const update = (event: MediaQueryListEvent) => setMatches(event.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [query]);
return matches;
}
function sameProjectContent(
left: SudokuProjectRecord,
right: SudokuProjectRecord,
): boolean {
return (
JSON.stringify({
title: left.title,
puzzle: left.puzzle,
progress: left.progress,
tags: left.tags ?? [],
}) ===
JSON.stringify({
title: right.title,
puzzle: right.puzzle,
progress: right.progress,
tags: right.tags ?? [],
})
);
}
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 [preservedDocumentExtras, setPreservedDocumentExtras] = useState<
PreservedSudokuDocumentExtras | undefined
>(boot.preservedExtras);
const [selection, setSelection] = useState<number[]>([0]);
const [activeCell, setActiveCell] = useState(0);
const [entryMode, setEntryMode] = useState<EntryMode>("value");
const [showCandidates, setShowCandidates] = useState(false);
const [candidateVerbosity, setCandidateVerbosity] =
useState<CandidateVerbosity>("detailed");
const [showConflicts, setShowConflicts] = useState(true);
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
const [tapMultiSelect, setTapMultiSelect] = useState(false);
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
const [guidedHint, setGuidedHint] = useState<LogicalStep>();
const [guidedHintStage, setGuidedHintStage] =
useState<GuidedHintStage>("focus");
const [guidedHintError, setGuidedHintError] = useState<string>();
const [guidedCandidateTracking, setGuidedCandidateTracking] = useState(false);
const [autoMaintainPeerNotes, setAutoMaintainPeerNotes] = useState(false);
const [candidateOverlay, setCandidateOverlay] = useState<CandidateOverlay>();
const [aidMemoire, setAidMemoire] = useState<AidMemoireState>(
boot.aidMemoire,
);
const [aidMemoireCell, setAidMemoireCell] = useState(0);
const [aidMemoireActive, setAidMemoireActive] = useState(false);
const [past, setPast] = useState<HistoryEntry[]>([]);
const [future, setFuture] = useState<HistoryEntry[]>([]);
const [gameplayHistory, setGameplayHistory] = useState<GameplayHistory>(() =>
createGameplayHistory(boot.session, boot.aidMemoire),
);
const [historyOpen, setHistoryOpen] = useState(false);
const [replayMomentId, setReplayMomentId] = useState<string>();
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 [quality, setQuality] = useState<PuzzleQualityAnalysis>();
const [qualityRunning, setQualityRunning] = useState<SetterQualityRunKind>();
const [qualityError, setQualityError] = useState<string>();
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
const [generationBatch, setGenerationBatch] =
useState<GeneratedVariantBatch>();
const [generationRunning, setGenerationRunning] = useState(false);
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 [recoverableAutosave, setRecoverableAutosave] =
useState<SudokuProjectRecord>();
const [autosaveChecked, setAutosaveChecked] = useState(false);
const [autosaveStatus, setAutosaveStatus] =
useState<AutosaveStatus>("checking");
const [autosaveMessage, setAutosaveMessage] = useState(
"Checking for recoverable local work…",
);
const workerRef = useRef<SolverWorkerClient | null>(null);
const qualityAbortRef = useRef<AbortController | null>(null);
const generationAbortRef = useRef<AbortController | null>(null);
const draggingRef = useRef(false);
const workbenchRef = useRef<HTMLElement>(null);
const replayReturnSessionRef = useRef<PlaySession | null>(null);
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(null);
const latestAutosaveStateRef = useRef({
puzzle,
session,
aidMemoire,
gameplayHistory,
preservedDocumentExtras,
solved: false,
currentProjectId,
});
const mobileLayout = useMediaQuery("(max-width: 48rem)");
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
const normalizedSourceVisuals = useMemo(
() =>
normalizeSafeVisualPrimitives(
preservedDocumentExtras?.visuals,
puzzle.size,
) ?? [],
[preservedDocumentExtras?.visuals, puzzle.size],
);
const replayMoment = useMemo(
() =>
replayMomentId === undefined
? undefined
: gameplayMoment(gameplayHistory, replayMomentId),
[gameplayHistory, replayMomentId],
);
const currentHypothesis = useMemo(
() => activeHypothesis(gameplayHistory),
[gameplayHistory],
);
const foggedCells = useMemo(
() => foggedCellsForPuzzle(normalized.puzzle, session.values),
[normalized.puzzle, session.values],
);
const effectiveSelection = useMemo(() => {
const visible = selection.filter((cell) => !foggedCells.has(cell));
if (visible.length > 0) return visible;
const firstVisible = session.values.findIndex((_, cell) =>
foggedCells.has(cell) ? false : true,
);
return firstVisible < 0 ? [] : [firstVisible];
}, [foggedCells, selection, session.values]);
const effectiveActiveCell = foggedCells.has(activeCell)
? (effectiveSelection[0] ?? activeCell)
: activeCell;
const selectedSet = useMemo(
() => new Set(effectiveSelection),
[effectiveSelection],
);
const boardSelectedSet = useMemo(
() => (aidMemoireActive ? new Set<number>() : selectedSet),
[aidMemoireActive, selectedSet],
);
const handleCandidateOverlayChange = useCallback(
(overlay: CandidateOverlay | undefined) => setCandidateOverlay(overlay),
[],
);
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 visibleGuidedHint =
guidedHint !== undefined && guidedHintStepIsVisible(guidedHint, foggedCells)
? guidedHint
: undefined;
const guidedBoardOverlay = useMemo(() => {
if (visibleGuidedHint === undefined) return undefined;
const overlay = guidedHintOverlay(visibleGuidedHint, guidedHintStage);
return {
focusCells: overlay.focusCells,
placements:
guidedHintStage === "preview"
? visibleGuidedHint.placements
: undefined,
eliminations:
guidedHintStage === "preview"
? visibleGuidedHint.eliminations
: undefined,
};
}, [guidedHintStage, visibleGuidedHint]);
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]);
useEffect(() => {
latestAutosaveStateRef.current = {
puzzle,
session,
aidMemoire,
gameplayHistory,
preservedDocumentExtras,
solved,
currentProjectId,
};
}, [
aidMemoire,
currentProjectId,
gameplayHistory,
preservedDocumentExtras,
puzzle,
session,
solved,
]);
const clearAnalysis = useCallback(() => {
setLogical(undefined);
setExact(undefined);
setDifficulty(undefined);
setGeneration(undefined);
setGenerationBatch(undefined);
setSolveError(undefined);
setGuidedHint(undefined);
setGuidedHintStage("focus");
setGuidedHintError(undefined);
qualityAbortRef.current?.abort();
qualityAbortRef.current = null;
setQuality(undefined);
setQualityRunning(undefined);
setQualityError(undefined);
}, []);
const currentHistory = useCallback(
(): HistoryEntry => ({
puzzle,
session: snapshotSession(session),
aidMemoire: cloneAidMemoire(aidMemoire),
guidedCandidateTracking,
}),
[aidMemoire, guidedCandidateTracking, puzzle, session],
);
const commit = useCallback(
(nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => {
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
setFuture([]);
setGameplayHistory((current) =>
workspace === "play" && nextPuzzle === puzzle
? recordGameplayMoment(
current,
nextSession,
describeGameplayChange(session, nextSession, puzzle.size),
aidMemoire,
)
: createGameplayHistory(nextSession, aidMemoire),
);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setPuzzle(nextPuzzle);
setSession(nextSession);
if (nextPuzzle !== puzzle) setGuidedCandidateTracking(false);
clearAnalysis();
setFeedback(undefined);
},
[aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace],
);
const loadPuzzle = useCallback(
(
nextPuzzle: PuzzleDefinition,
progress?: Pick<
SudokuDocument,
| "values"
| "cornerMarks"
| "centerMarks"
| "candidates"
| "colors"
| "elapsedMs"
| "aidMemoire"
>,
projectId?: string,
preservedExtras?: PreservedSudokuDocumentExtras,
): void => {
const valid = normalizePuzzle(nextPuzzle);
const nextSession = sessionFromDocument(valid, progress);
const nextAidMemoire = aidMemoireFromPortable(
progress?.aidMemoire,
valid.size,
);
setPuzzle(valid);
setSession(nextSession);
setPreservedDocumentExtras(preservedExtras);
setAidMemoire(nextAidMemoire);
setAidMemoireCell(0);
setAidMemoireActive(false);
setGameplayHistory(createGameplayHistory(nextSession, nextAidMemoire));
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setHistoryOpen(false);
setSelection([0]);
setActiveCell(0);
setHighlightedDigit(null);
setGuidedCandidateTracking(false);
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));
const nextSession = sessionFromProgress(valid, record.progress);
const nextAidMemoire = aidMemoireFromPortable(
record.progress?.aidMemoire ?? record.puzzle.aidMemoire,
valid.size,
);
setPuzzle(valid);
setSession(nextSession);
setPreservedDocumentExtras(extractPreservedDocumentExtras(record.puzzle));
setAidMemoire(nextAidMemoire);
setAidMemoireCell(0);
setAidMemoireActive(false);
setGameplayHistory(
record.progress?.gameplayHistory === undefined
? createGameplayHistory(nextSession, nextAidMemoire)
: parseGameplayHistory(record.progress.gameplayHistory, valid.size),
);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setHistoryOpen(false);
setSelection([0]);
setActiveCell(0);
setHighlightedDigit(null);
setGuidedCandidateTracking(false);
setPast([]);
setFuture([]);
setCurrentProjectId(
record.id === AUTOSAVE_DRAFT_ID ? undefined : 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(() => {
let cancelled = false;
void (async () => {
try {
setLibraryMode(await library.ready());
const autosave = await library.getAutosave();
const explicit =
autosave !== undefined && autosave.id !== AUTOSAVE_DRAFT_ID
? await library.get(autosave.id)
: undefined;
const recovered =
autosave !== undefined &&
explicit !== undefined &&
sameProjectContent(autosave, explicit)
? undefined
: autosave;
if (cancelled) return;
setRecoverableAutosave(recovered);
setAutosaveStatus(recovered === undefined ? "idle" : "saved");
setAutosaveMessage(
recovered === undefined
? "Autosave is ready."
: "Recoverable local work is available.",
);
} catch (error) {
if (cancelled) return;
setAutosaveStatus("error");
setAutosaveMessage(`Autosave unavailable: ${errorMessage(error)}`);
} finally {
if (!cancelled) setAutosaveChecked(true);
}
})();
return () => {
cancelled = true;
};
}, []);
const autosaveElapsedBucket = Math.floor(session.elapsedSeconds / 30);
useEffect(() => {
if (
!autosaveChecked ||
recoverableAutosave !== undefined ||
replayMomentId !== undefined
) {
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setAutosaveStatus("saving");
setAutosaveMessage("Saving work locally…");
void (async () => {
try {
const latest = latestAutosaveStateRef.current;
const now = Date.now();
const document = fromDomainPuzzle(
latest.puzzle,
latest.preservedDocumentExtras,
);
const progress = progressFromSession(
latest.puzzle,
latest.session,
latest.solved,
latest.aidMemoire,
latest.gameplayHistory,
);
const existing = latest.currentProjectId
? await library.get(latest.currentProjectId)
: undefined;
const record =
existing === undefined
? createProjectRecord(document, {
id: latest.currentProjectId ?? AUTOSAVE_DRAFT_ID,
title: latest.puzzle.title?.trim() || "Untitled puzzle",
progress,
now,
})
: {
...existing,
title: latest.puzzle.title?.trim() || "Untitled puzzle",
updatedAt: now,
puzzle: document,
progress,
thumbnail: undefined,
};
await library.putAutosave(record);
if (cancelled) return;
setAutosaveStatus("saved");
setAutosaveMessage(
`Saved locally at ${new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now)}.`,
);
} catch (error) {
if (cancelled) return;
setAutosaveStatus("error");
setAutosaveMessage(`Autosave failed: ${errorMessage(error)}`);
}
})();
}, AUTOSAVE_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
aidMemoire,
autosaveChecked,
autosaveElapsedBucket,
currentProjectId,
gameplayHistory,
preservedDocumentExtras,
puzzle,
recoverableAutosave,
replayMomentId,
session.centerMarks,
session.colors,
session.cornerMarks,
session.paused,
session.values,
solved,
]);
useEffect(() => {
workerRef.current = createSolverWorkerClient();
return () => {
qualityAbortRef.current?.abort();
qualityAbortRef.current = null;
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;
const restored = restoreSnapshot(session, target.session);
setFuture((entries) =>
[currentHistory(), ...entries].slice(0, MAX_HISTORY),
);
setPast(past.slice(0, -1));
setPuzzle(target.puzzle);
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
setGuidedCandidateTracking(target.guidedCandidateTracking);
setAidMemoireActive(false);
setGameplayHistory((current) =>
workspace === "play" && target.puzzle === puzzle
? recordGameplayMoment(current, restored, "Undo", target.aidMemoire)
: createGameplayHistory(restored, target.aidMemoire),
);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
clearAnalysis();
}, [clearAnalysis, currentHistory, past, puzzle, session, workspace]);
const redo = useCallback(() => {
const target = future[0];
if (target === undefined) return;
const restored = restoreSnapshot(session, target.session);
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
setFuture(future.slice(1));
setPuzzle(target.puzzle);
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
setGuidedCandidateTracking(target.guidedCandidateTracking);
setAidMemoireActive(false);
setGameplayHistory((current) =>
workspace === "play" && target.puzzle === puzzle
? recordGameplayMoment(current, restored, "Redo", target.aidMemoire)
: createGameplayHistory(restored, target.aidMemoire),
);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
clearAnalysis();
}, [clearAnalysis, currentHistory, future, puzzle, session, workspace]);
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;
setAidMemoireActive(false);
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);
if (tapMultiSelect) {
draggingRef.current = false;
changeSelection(cell, true, true);
return;
}
draggingRef.current = true;
const additive = event.shiftKey || command;
changeSelection(cell, additive, command);
},
[
changeSelection,
enableDigitHighlight,
puzzle.size,
session.paused,
session.values,
tapMultiSelect,
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 updateAidMemoire = useCallback(
(next: AidMemoireState, label = "Aid-mémoire updated") => {
if (next === aidMemoire) return;
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
setFuture([]);
setAidMemoire(next);
setGameplayHistory((current) =>
recordGameplayMoment(current, session, label, next),
);
if (!next.enabled) setAidMemoireActive(false);
setFeedback(undefined);
},
[aidMemoire, currentHistory, session],
);
const enterValue = useCallback(
(value: number, target: "auto" | "board" = "auto"): void => {
if (session.paused) return;
if (
target === "auto" &&
workspace === "play" &&
aidMemoire.enabled &&
aidMemoireActive
) {
updateAidMemoire(
enterAidMemoireCell(
aidMemoire,
aidMemoireCell,
entryMode,
value,
puzzle.size,
),
`Updated aid-mémoire cell ${String(aidMemoireCell + 1)}`,
);
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 },
);
setGuidedCandidateTracking(false);
return;
}
let nextSession = enterSelection(
session,
selectedSet,
entryMode,
value,
puzzle.givens,
);
if (entryMode === "value" && autoMaintainPeerNotes) {
nextSession = autoRemoveNotesAfterPlacements(
session,
nextSession,
normalized.puzzle,
);
}
commit(puzzle, nextSession);
if (
entryMode === "center" ||
(entryMode === "value" &&
nextSession.values.some(
(nextValue, cell) =>
(session.values[cell] ?? 0) !== 0 && nextValue === 0,
))
) {
setGuidedCandidateTracking(false);
}
},
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
autoMaintainPeerNotes,
commit,
entryMode,
normalized.puzzle,
puzzle,
selectedSet,
session,
updateAidMemoire,
workspace,
],
);
const erase = useCallback(
(target: "auto" | "board" = "auto"): void => {
if (session.paused) return;
if (
target === "auto" &&
workspace === "play" &&
aidMemoire.enabled &&
aidMemoireActive
) {
updateAidMemoire(
eraseAidMemoireCell(aidMemoire, aidMemoireCell, entryMode),
`Erased aid-mémoire cell ${String(aidMemoireCell + 1)}`,
);
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 });
setGuidedCandidateTracking(false);
return;
}
commit(
puzzle,
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
);
if (entryMode === "value" || entryMode === "center") {
setGuidedCandidateTracking(false);
}
},
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
commit,
entryMode,
puzzle,
selectedSet,
session,
updateAidMemoire,
workspace,
],
);
const moveActive = useCallback(
(key: string, extend: boolean, command: boolean): void => {
const next = navigateGridCell(
effectiveActiveCell,
puzzle.size,
key,
command,
);
if (next === null) return;
changeSelection(next, extend, false);
requestAnimationFrame(() => {
workbenchRef.current
?.querySelector<HTMLButtonElement>(`[data-cell="${String(next)}"]`)
?.focus();
});
},
[changeSelection, effectiveActiveCell, puzzle.size],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
setAidMemoireActive(false);
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;
}
if (!command && event.key.toLowerCase() === "m") {
event.preventDefault();
setTapMultiSelect((current) => !current);
return;
}
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
].includes(event.key)
) {
event.preventDefault();
moveActive(event.key, event.shiftKey, command);
return;
}
if (
event.key === "Backspace" ||
event.key === "Delete" ||
event.key === "0"
) {
event.preventDefault();
erase("board");
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, "board");
}
}
},
[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);
setGuidedHintError(undefined);
try {
const result = (await workerRef.current.request(
{
kind: "logical",
puzzle: normalized.puzzle,
options: {
values: session.values,
maxSteps: foggedCells.size > 0 ? 32 : 1,
...(guidedCandidateTracking
? {
candidates: session.centerMarks.map((mask) =>
maskValues(mask, puzzle.size),
),
}
: {}),
},
},
{ timeoutMs: 15_000 },
)) as LogicalSolveResult;
const step = result.steps.find((candidate) =>
guidedHintStepIsVisible(candidate, foggedCells),
);
if (step === undefined) {
setGuidedHint(undefined);
setGuidedHintStage("focus");
setFeedback({
kind: result.status === "solved" ? "success" : "info",
message:
result.steps.length > 0 && foggedCells.size > 0
? "No currently visible logical hint was found. Reveal more of the fog by solving a visible cell."
: result.status === "solved"
? "The grid is already solved."
: "No supported logical next step was found.",
});
} else {
setGuidedHint(step);
setGuidedHintStage("focus");
setFeedback(undefined);
}
} catch (error) {
setGuidedHintError(errorMessage(error));
} finally {
setBusy(false);
}
}, [foggedCells, guidedCandidateTracking, normalized, puzzle.size, session]);
const revealNextHintStage = useCallback(() => {
setGuidedHintStage((current) => nextGuidedHintStage(current) ?? current);
}, []);
const applyGuidedHint = useCallback(() => {
if (guidedHint === undefined || normalized.error) return;
if (!guidedHintStepIsVisible(guidedHint, foggedCells)) {
setGuidedHint(undefined);
setGuidedHintStage("focus");
setFeedback({
kind: "info",
message:
"That hint is no longer visible under the fog. Request another hint.",
});
return;
}
try {
const startsTracking =
!guidedCandidateTracking && guidedHint.eliminations.length > 0;
const baseSession = startsTracking
? fillLegalCenterCandidates(session, normalized.puzzle)
: session;
const nextSession = applyLogicalStepToSession(
baseSession,
guidedHint,
normalized.puzzle,
);
commit(puzzle, nextSession);
if (startsTracking) setGuidedCandidateTracking(true);
setFeedback({
kind: "success",
message: `Applied ${guidedHint.technique.replaceAll("-", " ")} as one undoable step${startsTracking ? " and started a complete candidate grid" : ""}.`,
});
} catch (error) {
setGuidedHintError(errorMessage(error));
}
}, [
commit,
foggedCells,
guidedCandidateTracking,
guidedHint,
normalized,
puzzle,
session,
]);
const fillGuidedCandidates = useCallback(() => {
if (normalized.error) return;
try {
const nextSession = fillLegalCenterCandidates(session, normalized.puzzle);
commit(puzzle, nextSession);
setGuidedCandidateTracking(true);
setFeedback({
kind: "success",
message:
"Filled every empty cell with its currently legal centre candidates. Undo restores your previous notes.",
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
}, [commit, normalized, puzzle, session]);
const removeInvalidCandidateNotes = useCallback(() => {
if (normalized.error) return;
try {
const nextSession = pruneInvalidNotes(session, normalized.puzzle);
const changed =
nextSession.cornerMarks.some(
(mask, cell) => mask !== session.cornerMarks[cell],
) ||
nextSession.centerMarks.some(
(mask, cell) => mask !== session.centerMarks[cell],
);
if (!changed) {
setFeedback({ kind: "info", message: "All notes are already legal." });
return;
}
commit(puzzle, nextSession);
setFeedback({
kind: "success",
message: "Removed currently impossible corner and centre notes.",
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
}, [commit, normalized, puzzle, session]);
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 >= 2 || (result.count === 0 && !result.truncated)
? "error"
: "info",
message:
result.count >= 2
? "The definition has multiple solutions."
: result.truncated
? result.count === 0
? "Search reached a safety limit before finding a solution; solvability is still unknown."
: "A solution was found, but uniqueness was not established before a safety limit."
: result.count === 0
? "The definition has no solution."
: `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 runQualityAudit = useCallback(
async (kind: SetterQualityRunKind, options: PuzzleQualityOptions) => {
if (workerRef.current === null) {
setQualityError("Solver worker is not ready yet.");
return;
}
qualityAbortRef.current?.abort();
const controller = new AbortController();
qualityAbortRef.current = controller;
setBusy(true);
setQualityRunning(kind);
setQualityError(undefined);
try {
const result = (await workerRef.current.request(
{ kind: "quality", puzzle, options },
{
signal: controller.signal,
timeoutMs: Math.min(
300_000,
(options.aggregateTimeoutMs ?? 30_000) + 15_000,
),
},
)) as PuzzleQualityAnalysis;
if (!controller.signal.aborted) setQuality(result);
} catch (error) {
if (!controller.signal.aborted) setQualityError(errorMessage(error));
} finally {
if (qualityAbortRef.current === controller) {
qualityAbortRef.current = null;
setQualityRunning(undefined);
setBusy(false);
}
}
},
[puzzle],
);
const cancelQualityAudit = useCallback(() => {
qualityAbortRef.current?.abort();
qualityAbortRef.current = null;
setQualityRunning(undefined);
setBusy(false);
setQualityError(undefined);
setFeedback({
kind: "info",
message:
"Setter-quality analysis cancelled; the previous result remains visible.",
});
}, []);
const generateConfigured = useCallback(
async (options: GenerateVariantOptions) => {
if (workerRef.current === null) return;
generationAbortRef.current?.abort();
const controller = new AbortController();
generationAbortRef.current = controller;
setGenerationRunning(true);
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, signal: controller.signal },
)) as GeneratedVariantPuzzle;
if (controller.signal.aborted) return;
loadPuzzle(generated.puzzle);
setGeneration(generated);
setGenerationBatch(undefined);
setDifficulty(generated.difficulty);
setWorkspace("generate");
setFeedback({
kind: "success",
message: `Generated a unique ${generated.variant} puzzle${generated.requestedTechnique === undefined ? "" : ` featuring ${generated.requestedTechnique.replaceAll("-", " ")}`}. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
});
} catch (error) {
if (!controller.signal.aborted) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
} finally {
if (generationAbortRef.current === controller) {
generationAbortRef.current = null;
setGenerationRunning(false);
setBusy(false);
}
}
},
[loadPuzzle],
);
const generateBatchConfigured = useCallback(
async (options: GenerateVariantBatchOptions) => {
if (workerRef.current === null) return;
generationAbortRef.current?.abort();
const controller = new AbortController();
generationAbortRef.current = controller;
setGenerationRunning(true);
setBusy(true);
setFeedback({
kind: "info",
message:
"Generating, independently checking and ranking a bounded local batch. The current board stays available until a result is ready…",
});
try {
const batch = (await workerRef.current.request(
{ kind: "generate-batch", options },
{ timeoutMs: 300_000, signal: controller.signal },
)) as GeneratedVariantBatch;
if (controller.signal.aborted) return;
const best = batch.entries[0];
if (best !== undefined) {
loadPuzzle(best.puzzle);
setGeneration(best);
setDifficulty(best.difficulty);
}
setGenerationBatch(batch);
setWorkspace("generate");
setFeedback({
kind: batch.completed > 0 ? "success" : "error",
message:
batch.completed > 0
? `Generated and ranked ${String(batch.completed)} of ${String(batch.requested)} bounded candidates; the top result is open.`
: "No batch candidate completed every bounded verification.",
});
} catch (error) {
if (!controller.signal.aborted) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
} finally {
if (generationAbortRef.current === controller) {
generationAbortRef.current = null;
setGenerationRunning(false);
setBusy(false);
}
}
},
[loadPuzzle],
);
const selectGeneratedCandidate = useCallback(
(generated: GeneratedVariantPuzzle) => {
const batch = generationBatch;
loadPuzzle(generated.puzzle);
setGeneration(generated);
setDifficulty(generated.difficulty);
setGenerationBatch(batch);
setWorkspace("generate");
setFeedback({
kind: "success",
message: `Opened ranked candidate ${String(generated.seed)}.`,
});
},
[generationBatch, loadPuzzle],
);
const cancelGeneration = useCallback(() => {
generationAbortRef.current?.abort();
generationAbortRef.current = null;
setGenerationRunning(false);
setBusy(false);
setFeedback({
kind: "info",
message: "Generation cancelled; the current puzzle was left unchanged.",
});
}, []);
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);
setGenerationBatch(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, preservedDocumentExtras);
const progress = progressFromSession(
puzzle,
session,
solved,
aidMemoire,
gameplayHistory,
);
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,
thumbnail: undefined,
};
} 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);
}
}, [
aidMemoire,
currentProjectId,
gameplayHistory,
preservedDocumentExtras,
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 exportSelectedProjects = useCallback(async (ids: readonly string[]) => {
setLibraryBusy(true);
try {
const exported = await library.exportSelected(ids);
if (exported.projects.length === 0) {
throw new Error("Select at least one saved project to export.");
}
downloadJson("sudoku-tools-selection.json", exported);
setLibraryFeedback(
`${String(exported.projects.length)} selected project${exported.projects.length === 1 ? "" : "s"} exported.`,
);
} catch (error) {
setLibraryFeedback(errorMessage(error));
} finally {
setLibraryBusy(false);
}
}, []);
const duplicateSelectedProjects = useCallback(
async (ids: readonly string[]) => {
setLibraryBusy(true);
try {
const count = await library.duplicateSelected(ids);
await refreshLibrary();
setLibraryFeedback(
`${String(count)} project${count === 1 ? "" : "s"} copied locally.`,
);
} catch (error) {
setLibraryFeedback(errorMessage(error));
} finally {
setLibraryBusy(false);
}
},
[refreshLibrary],
);
const deleteSelectedProjects = useCallback(
async (ids: readonly string[]) => {
if (
ids.length === 0 ||
!window.confirm(
`Delete ${String(ids.length)} selected saved project${ids.length === 1 ? "" : "s"} from this browser?`,
)
) {
return;
}
setLibraryBusy(true);
try {
for (const id of ids) await library.delete(id);
if (currentProjectId && ids.includes(currentProjectId)) {
setCurrentProjectId(undefined);
}
await refreshLibrary();
setLibraryFeedback("Selected projects deleted.");
} catch (error) {
setLibraryFeedback(errorMessage(error));
} finally {
setLibraryBusy(false);
}
},
[currentProjectId, refreshLibrary],
);
const updateProjectTags = useCallback(
async (id: string, tags: readonly string[]) => {
setLibraryBusy(true);
try {
const record = await library.get(id);
if (record === undefined) {
throw new Error("The saved puzzle no longer exists.");
}
await library.put({ ...record, tags, updatedAt: Date.now() });
await refreshLibrary();
setLibraryFeedback("Project tags updated.");
} catch (error) {
setLibraryFeedback(errorMessage(error));
} finally {
setLibraryBusy(false);
}
},
[refreshLibrary],
);
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 restoreAutosave = useCallback(() => {
if (recoverableAutosave === undefined) return;
try {
restoreProject(recoverableAutosave);
setRecoverableAutosave(undefined);
setAutosaveStatus("saved");
setAutosaveMessage("Recovered local work; autosave is active.");
} catch (error) {
setAutosaveStatus("error");
setAutosaveMessage(`Recovery failed: ${errorMessage(error)}`);
}
}, [recoverableAutosave, restoreProject]);
const discardAutosave = useCallback(async () => {
try {
await library.clearAutosave();
setRecoverableAutosave(undefined);
setAutosaveStatus("idle");
setAutosaveMessage(
"Previous recovery data discarded; autosave is active.",
);
} catch (error) {
setAutosaveStatus("error");
setAutosaveMessage(
`Could not discard recovery data: ${errorMessage(error)}`,
);
}
}, []);
const applyValues = useCallback(
(values: readonly number[]) => {
commit(puzzle, { ...session, values: [...values] });
setGuidedCandidateTracking(false);
},
[commit, puzzle, session],
);
const focusCells = useCallback((cells: readonly number[]) => {
if (cells.length === 0) return;
setSelection([...cells]);
setActiveCell(cells[0]!);
setAidMemoireActive(false);
}, []);
const focusQualityItem = useCallback(
(item: QualityItemReference) => {
focusCells(qualityItemCells(normalized.puzzle, item));
},
[focusCells, normalized.puzzle],
);
const createNamedSavepoint = useCallback(
(name: string) => {
try {
setGameplayHistory(
createSavepoint(gameplayHistory, session, name, aidMemoire),
);
setFeedback({
kind: "success",
message: `Saved “${name.trim()}” locally for this solve.`,
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
},
[aidMemoire, gameplayHistory, session],
);
const restoreNamedSavepoint = useCallback(
(savepointId: string) => {
try {
const transition = restoreSavepoint(gameplayHistory, savepointId);
const restored = sessionFromGameplayState(transition.state);
setPast((entries) =>
[...entries, currentHistory()].slice(-MAX_HISTORY),
);
setFuture([]);
setGameplayHistory(transition.history);
setSession(restored);
setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
setAidMemoireActive(false);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setHistoryOpen(false);
clearAnalysis();
setFeedback({ kind: "success", message: "Savepoint restored." });
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
},
[clearAnalysis, currentHistory, gameplayHistory, puzzle.size],
);
const startHypothesis = useCallback(
(name: string, fromMomentId?: string) => {
try {
const transition = beginHypothesis(
gameplayHistory,
session,
name,
fromMomentId,
aidMemoire,
);
setGameplayHistory(transition.history);
setSession(sessionFromGameplayState(transition.state));
setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
setAidMemoireActive(false);
setPast([]);
setFuture([]);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setHistoryOpen(false);
clearAnalysis();
setFeedback({
kind: "info",
message: `Hypothesis “${name.trim()}” started. Its moves are isolated until you keep or discard them.`,
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
},
[aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session],
);
const completeHypothesis = useCallback(
(decision: "keep" | "discard") => {
try {
const transition = finishHypothesis(
gameplayHistory,
session,
decision,
aidMemoire,
);
setGameplayHistory(transition.history);
setSession(sessionFromGameplayState(transition.state));
setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
setAidMemoireActive(false);
setPast([]);
setFuture([]);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
setHistoryOpen(false);
clearAnalysis();
setFeedback({
kind: "success",
message:
decision === "keep"
? "Hypothesis changes kept in the main solve."
: "Hypothesis discarded; its branch remains available in replay.",
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
}
},
[aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session],
);
const replayHistoryMoment = useCallback(
(momentId: string) => {
const moment = gameplayMoment(gameplayHistory, momentId);
if (moment === undefined) return;
if (replayReturnSessionRef.current === null) {
replayReturnSessionRef.current = session;
replayReturnAidMemoireRef.current = aidMemoire;
}
setSession(sessionFromGameplayState(moment.state, true));
setAidMemoire(aidMemoireFromGameplayState(moment.state, puzzle.size));
setAidMemoireActive(false);
setReplayMomentId(moment.id);
setHistoryOpen(false);
setHighlightedDigit(null);
},
[aidMemoire, gameplayHistory, puzzle.size, session],
);
const returnToLiveGrid = useCallback(() => {
const live = replayReturnSessionRef.current;
const liveAidMemoire = replayReturnAidMemoireRef.current;
if (live !== null) setSession(live);
if (liveAidMemoire !== null) setAidMemoire(liveAidMemoire);
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setAidMemoireActive(false);
setReplayMomentId(undefined);
}, []);
const activeConstraints = useMemo(() => {
const types: string[] = [
...new Set(normalized.puzzle.constraints.map((item) => item.type)),
];
if (
normalized.puzzle.constraints.some(
(item) => "negated" in item && item.negated === true,
)
) {
types.push("false-clues");
}
return types;
}, [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}
disabled={currentHypothesis !== undefined && id !== "play"}
title={
currentHypothesis !== undefined && id !== "play"
? "Keep or discard the active hypothesis first"
: undefined
}
onClick={() => {
if (id !== "play") returnToLiveGrid();
if (id !== "play") setAidMemoireActive(false);
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>
<span
className={`autosave-status autosave-status--${autosaveStatus}`}
title={autosaveMessage}
>
{autosaveStatus === "checking"
? "Autosave checking"
: autosaveStatus === "saving"
? "Autosaving…"
: autosaveStatus === "saved"
? "Autosaved"
: autosaveStatus === "error"
? "Autosave issue"
: "Autosave ready"}
</span>
</div>
{recoverableAutosave && (
<section className="recovery-callout" aria-labelledby="recovery-title">
<div>
<strong id="recovery-title">Recover unsaved local work?</strong>
<p>
{recoverableAutosave.title || "Untitled puzzle"} was autosaved{" "}
{new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(recoverableAutosave.updatedAt)}
.
</p>
</div>
<div className="action-row">
<button type="button" onClick={restoreAutosave}>
Restore
</button>
<button
type="button"
className="text-button"
onClick={() => void discardAutosave()}
>
Discard
</button>
</div>
</section>
)}
{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 || replayMoment !== undefined}
onClick={undo}
>
Undo
</button>
<button
type="button"
disabled={!future.length || replayMoment !== undefined}
onClick={redo}
>
Redo
</button>
<button
type="button"
className={tapMultiSelect ? "is-active" : ""}
aria-pressed={tapMultiSelect}
title="Toggle cells with individual taps; dragging is disabled (M)"
onClick={() => setTapMultiSelect((current) => !current)}
>
Tap multi-select
</button>
{workspace === "play" && (
<button type="button" onClick={() => setHistoryOpen(true)}>
{currentHypothesis
? `Hypothesis: ${currentHypothesis.name}`
: "History & branches"}
</button>
)}
{replayMoment && (
<button type="button" onClick={returnToLiveGrid}>
Return live
</button>
)}
</div>
<span>
{replayMoment
? `Replay · ${replayMoment.label}`
: aidMemoireActive && aidMemoire.enabled
? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}`
: effectiveSelection.length === 1
? cellLabel(effectiveSelection[0]!, puzzle.size)
: `${String(effectiveSelection.length)} cells selected`}
</span>
</div>
<div
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
>
<BoardViewport
onPanModeChange={() => {
draggingRef.current = false;
}}
>
<SudokuBoard
puzzle={normalized.puzzle}
values={session.values}
cornerMarks={session.cornerMarks}
centerMarks={session.centerMarks}
colors={session.colors}
candidates={candidateMasks}
selected={boardSelectedSet}
highlighted={highlightedCells}
conflicts={conflictCells}
activeCell={effectiveActiveCell}
showCandidates={showCandidates}
candidateVerbosity={candidateVerbosity}
candidateOverlay={
workspace === "helpers" ? candidateOverlay : undefined
}
guidedHintOverlay={
workspace === "play" ? guidedBoardOverlay : undefined
}
qualityHeatmap={
workspace === "set" ? quality?.criticalityHeatmap : undefined
}
visuals={normalizedSourceVisuals}
onCellPointerDown={handlePointerDown}
onCellPointerEnter={handlePointerEnter}
onKeyDown={handleKeyDown}
/>
</BoardViewport>
{session.paused && replayMoment === undefined && (
<button
type="button"
className="paused-cover"
onClick={() =>
setSession((current) => ({ ...current, paused: false }))
}
>
<strong>Paused</strong>
<span>Resume puzzle</span>
</button>
)}
</div>
{mobileLayout &&
(workspace === "play" || workspace === "set") &&
replayMoment === undefined && (
<div className="mobile-number-pad" aria-label="Sticky entry pad">
<NumberPad
size={puzzle.size}
mode={entryMode}
onMode={setEntryMode}
onValue={enterValue}
onErase={erase}
/>
</div>
)}
{workspace === "play" && showDigitCompletion && (
<DigitCompletionBar
size={puzzle.size}
completions={completionData}
highlightedDigit={highlightedDigit}
highlightingEnabled={enableDigitHighlight}
onHighlight={(digit) =>
setHighlightedDigit((current) =>
current === digit ? null : digit,
)
}
/>
)}
{workspace === "play" &&
aidMemoire.enabled &&
(!session.paused || replayMoment !== undefined) && (
<AidMemoire
size={puzzle.size}
state={aidMemoire}
selectedCell={aidMemoireCell}
active={aidMemoireActive && replayMoment === undefined}
readOnly={replayMoment !== undefined}
mode={entryMode}
onStateChange={updateAidMemoire}
onSelect={(cell) => {
setAidMemoireCell(cell);
setAidMemoireActive(true);
setHighlightedDigit(null);
}}
onMode={setEntryMode}
/>
)}
<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" && replayMoment && (
<div className="play-panel stack">
<div>
<p className="eyebrow">Read-only replay</p>
<h2>{replayMoment.label}</h2>
<p className="muted">
This is the complete grid at{" "}
{formatTime(replayMoment.state.elapsedSeconds)}. Return to the
live grid or start a hypothesis from here.
</p>
</div>
<div className="action-row">
<button type="button" onClick={returnToLiveGrid}>
Return to live grid
</button>
<button type="button" onClick={() => setHistoryOpen(true)}>
Open history
</button>
</div>
</div>
)}
{workspace === "play" && replayMoment === undefined && (
<div className="play-panel stack">
<div>
<p className="eyebrow">Play locally</p>
<h2>Enter your solve</h2>
<p className="muted">
{aidMemoireActive && aidMemoire.enabled
? "The keypad is editing the selected aid-mémoire cell. Select the Sudoku grid to return to normal entry."
: "Select one or several cells, then use the keypad or keyboard."}
</p>
</div>
{!mobileLayout && (
<NumberPad
size={puzzle.size}
mode={entryMode}
onMode={setEntryMode}
onValue={enterValue}
onErase={erase}
/>
)}
<GuidedHint
size={puzzle.size}
step={visibleGuidedHint}
stage={guidedHintStage}
busy={busy || session.paused}
error={guidedHintError}
candidateTrackingActive={guidedCandidateTracking}
autoMaintainPeerNotes={autoMaintainPeerNotes}
onRequestHint={() => void requestHint()}
onRevealNext={revealNextHintStage}
onApply={applyGuidedHint}
onDismiss={() => {
setGuidedHint(undefined);
setGuidedHintStage("focus");
setGuidedHintError(undefined);
}}
onFillLegalCandidates={fillGuidedCandidates}
onRemoveInvalidNotes={removeInvalidCandidateNotes}
onAutoMaintainPeerNotesChange={setAutoMaintainPeerNotes}
/>
<section className="panel-section">
<div className="action-row">
<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="accessibility-select">
<span>Screen-reader candidate detail</span>
<select
value={candidateVerbosity}
onChange={(event) =>
setCandidateVerbosity(
parseCandidateVerbosity(event.target.value),
)
}
>
<option value="off">Off</option>
<option value="concise">Concise counts</option>
<option value="detailed">Detailed digits</option>
</select>
</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>
<label className="option-row">
<input
type="checkbox"
checked={aidMemoire.enabled}
onChange={(event) => {
const enabled = event.target.checked;
updateAidMemoire(
setAidMemoireEnabled(aidMemoire, enabled),
enabled ? "Aid-mémoire shown" : "Aid-mémoire hidden",
);
setAidMemoireActive(enabled);
if (enabled) setAidMemoireCell(0);
}}
/>
Show aid-mémoire scratch cells
</label>
</section>
<section className="panel-section">
<button
type="button"
className="danger"
onClick={() => {
commit(puzzle, createSession(puzzle.givens));
setGuidedCandidateTracking(false);
}}
>
Restart puzzle
</button>
</section>
<p className="muted shortcut-note">
Z/X/C/V change mode · arrows move · Home/End move across a row ·
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>
{!mobileLayout && (
<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")}
/>
<SetterQualityLab
size={puzzle.size}
result={quality}
running={qualityRunning}
error={qualityError}
onRunQuick={(options) => void runQualityAudit("quick", options)}
onRunMinimality={(options) =>
void runQualityAudit("minimality", options)
}
onCancel={cancelQualityAudit}
onFocusCells={focusCells}
onFocusItem={focusQualityItem}
/>
</div>
)}
{workspace === "generate" && (
<GeneratorWorkspace
busy={busy}
assessment={difficulty}
generation={generation}
batch={generationBatch}
onGenerate={(options) => void generateConfigured(options)}
onGenerateBatch={(options) =>
void generateBatchConfigured(options)
}
onSelectGenerated={selectGeneratedCandidate}
onCancel={generationRunning ? cancelGeneration : undefined}
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
key={puzzle.size}
size={puzzle.size}
puzzle={normalized.puzzle}
values={session.values}
selectedCells={effectiveSelection}
candidateMasks={candidateMasks}
onCandidateOverlayChange={handleCandidateOverlayChange}
/>
)}
</aside>
</div>
<ImportExportDialog
open={importOpen}
puzzle={puzzle}
session={session}
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
preservedExtras={preservedDocumentExtras}
onClose={() => setImportOpen(false)}
onImport={(next, progress, preservedExtras) => {
try {
loadPuzzle(next, progress, undefined, preservedExtras);
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()}
onExportSelected={(ids) => void exportSelectedProjects(ids)}
onDuplicateSelected={(ids) => void duplicateSelectedProjects(ids)}
onDeleteSelected={(ids) => void deleteSelectedProjects(ids)}
onUpdateTags={(id, tags) => void updateProjectTags(id, tags)}
onImport={(file) => void importLibrary(file)}
/>
<GameplayHistoryDialog
open={historyOpen}
history={gameplayHistory}
replayMomentId={replayMomentId}
onClose={() => setHistoryOpen(false)}
onCreateSavepoint={createNamedSavepoint}
onRestoreSavepoint={restoreNamedSavepoint}
onDeleteSavepoint={(savepointId) =>
setGameplayHistory((current) => deleteSavepoint(current, savepointId))
}
onStartHypothesis={startHypothesis}
onFinishHypothesis={completeHypothesis}
onReplayMoment={replayHistoryMoment}
onReturnLive={returnToLiveGrid}
/>
</main>
);
}