feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+557 -73
View File
@@ -24,6 +24,7 @@ import {
toDomainPuzzle,
type SudokuDocument,
} from "../formats";
import type { CandidateOverlay } from "../helpers";
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
import type {
DifficultyAssessment,
@@ -57,9 +58,37 @@ import {
matchingDigitCells,
toggledDigitHighlight,
} from "../state/gameplayHelpers";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
setAidMemoireEnabled,
type AidMemoireState,
} from "../state/aidMemoire";
import { navigateGridCell } from "../state/gridNavigation";
import {
activeHypothesis,
aidMemoireFromGameplayState,
beginHypothesis,
createGameplayHistory,
createSavepoint,
deleteSavepoint,
describeGameplayChange,
finishHypothesis,
gameplayMoment,
recordGameplayMoment,
restoreSavepoint,
sessionFromGameplayState,
type GameplayHistory,
} from "../state/playHistory";
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor";
import { AidMemoire } from "./AidMemoire";
import { DigitCompletionBar } from "./DigitCompletionBar";
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
import { GeneratorWorkspace } from "./GeneratorWorkspace";
import { HelpersWorkspace } from "./HelpersWorkspace";
import { ImportExportDialog } from "./ImportExportDialog";
@@ -79,6 +108,7 @@ interface Feedback {
interface HistoryEntry {
readonly puzzle: PuzzleDefinition;
readonly session: PlaySnapshot;
readonly aidMemoire: AidMemoireState;
}
const library = createProjectLibrary();
@@ -143,6 +173,7 @@ function progressFromSession(
puzzle: PuzzleDefinition,
session: PlaySession,
completed: boolean,
aidMemoire: AidMemoireState,
): SudokuProgress {
return {
version: 1,
@@ -156,6 +187,7 @@ function progressFromSession(
colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000,
completed,
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
};
}
@@ -195,18 +227,27 @@ function safeNormalize(puzzle: PuzzleDefinition): {
function initialProject(): {
readonly puzzle: PuzzleDefinition;
readonly session: PlaySession;
readonly aidMemoire: AidMemoireState;
} {
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) };
return {
puzzle,
session: sessionFromDocument(puzzle, document),
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
};
} catch {
// An invalid hash is non-fatal; the import dialog can report details.
}
}
const puzzle = normalizePuzzle(CLASSIC_SAMPLE);
return { puzzle, session: createSession(puzzle.givens) };
return {
puzzle,
session: createSession(puzzle.givens),
aidMemoire: createAidMemoire(puzzle.size),
};
}
function formatTime(seconds: number): string {
@@ -245,6 +286,8 @@ function errorMessage(error: unknown): string {
function constraintLabel(type: string): string {
if (type === "xv") return "Sum pairs (5/10)";
if (type === "x-sum") return "X-sums";
if (type === "false-clues") return "False clues";
return type
.split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1))
@@ -264,8 +307,19 @@ export function Workbench() {
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
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>();
@@ -286,9 +340,30 @@ export function Workbench() {
const workerRef = useRef<SolverWorkerClient | null>(null);
const draggingRef = useRef(false);
const workbenchRef = useRef<HTMLElement>(null);
const replayReturnSessionRef = useRef<PlaySession | null>(null);
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(null);
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
const replayMoment = useMemo(
() =>
replayMomentId === undefined
? undefined
: gameplayMoment(gameplayHistory, replayMomentId),
[gameplayHistory, replayMomentId],
);
const currentHypothesis = useMemo(
() => activeHypothesis(gameplayHistory),
[gameplayHistory],
);
const selectedSet = useMemo(() => new Set(selection), [selection]);
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],
@@ -345,20 +420,37 @@ export function Workbench() {
}, []);
const currentHistory = useCallback(
(): HistoryEntry => ({ puzzle, session: snapshotSession(session) }),
[puzzle, session],
(): HistoryEntry => ({
puzzle,
session: snapshotSession(session),
aidMemoire: cloneAidMemoire(aidMemoire),
}),
[aidMemoire, 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);
clearAnalysis();
setFeedback(undefined);
},
[clearAnalysis, currentHistory],
[aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace],
);
const loadPuzzle = useCallback(
@@ -372,12 +464,26 @@ export function Workbench() {
| "candidates"
| "colors"
| "elapsedMs"
| "aidMemoire"
>,
projectId?: string,
): void => {
const valid = normalizePuzzle(nextPuzzle);
const nextSession = sessionFromDocument(valid, progress);
const nextAidMemoire = aidMemoireFromPortable(
progress?.aidMemoire,
valid.size,
);
setPuzzle(valid);
setSession(sessionFromDocument(valid, progress));
setSession(nextSession);
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);
@@ -393,8 +499,21 @@ export function Workbench() {
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(sessionFromProgress(valid, record.progress));
setSession(nextSession);
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);
@@ -451,24 +570,46 @@ export function Workbench() {
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((current) => restoreSnapshot(current, target.session));
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
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]);
}, [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((current) => restoreSnapshot(current, target.session));
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
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]);
}, [clearAnalysis, currentHistory, future, puzzle, session, workspace]);
const changeSelection = useCallback(
(cell: number, additive: boolean, toggle: boolean): void => {
@@ -488,6 +629,7 @@ export function Workbench() {
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;
@@ -528,9 +670,42 @@ export function Workbench() {
[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): void => {
(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];
@@ -554,37 +729,68 @@ export function Workbench() {
enterSelection(session, selectedSet, entryMode, value, puzzle.givens),
);
},
[commit, entryMode, puzzle, selectedSet, session, workspace],
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
commit,
entryMode,
puzzle,
selectedSet,
session,
updateAidMemoire,
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;
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;
}
commit({ ...puzzle, givens }, { ...session, values });
return;
}
commit(
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),
);
},
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
commit,
entryMode,
puzzle,
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
);
}, [commit, entryMode, puzzle, selectedSet, session, workspace]);
selectedSet,
session,
updateAidMemoire,
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;
(key: string, extend: boolean, command: boolean): void => {
const next = navigateGridCell(activeCell, puzzle.size, key, command);
if (next === null) return;
changeSelection(next, extend, false);
requestAnimationFrame(() => {
workbenchRef.current
@@ -597,6 +803,7 @@ export function Workbench() {
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
setAidMemoireActive(false);
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === "z") {
event.preventDefault();
@@ -620,16 +827,20 @@ export function Workbench() {
);
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) {
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
].includes(event.key)
) {
event.preventDefault();
moveActive(delta[0], delta[1], event.shiftKey);
moveActive(event.key, event.shiftKey, command);
return;
}
if (
@@ -638,7 +849,7 @@ export function Workbench() {
event.key === "0"
) {
event.preventDefault();
erase();
erase("board");
return;
}
if (!command) {
@@ -657,7 +868,7 @@ export function Workbench() {
const value = valueForKey(event.key, puzzle.size);
if (value !== null) {
event.preventDefault();
enterValue(value);
enterValue(value, "board");
}
}
},
@@ -789,16 +1000,18 @@ export function Workbench() {
kind:
result.count === 1 && !result.truncated
? "success"
: result.count === 0 || result.count >= 2
: result.count >= 2 || (result.count === 0 && !result.truncated)
? "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."
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) {
@@ -828,7 +1041,7 @@ export function Workbench() {
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)`}.`,
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) {
setFeedback({ kind: "error", message: errorMessage(error) });
@@ -899,7 +1112,7 @@ export function Workbench() {
try {
const now = Date.now();
const document = fromDomainPuzzle(puzzle);
const progress = progressFromSession(puzzle, session, solved);
const progress = progressFromSession(puzzle, session, solved, aidMemoire);
let record: SudokuProjectRecord;
const existing = currentProjectId
? await library.get(currentProjectId)
@@ -928,7 +1141,7 @@ export function Workbench() {
} finally {
setLibraryBusy(false);
}
}, [currentProjectId, puzzle, refreshLibrary, session, solved]);
}, [aidMemoire, currentProjectId, puzzle, refreshLibrary, session, solved]);
const openProject = useCallback(
async (id: string) => {
@@ -1032,13 +1245,167 @@ export function Workbench() {
if (cells.length === 0) return;
setSelection([...cells]);
setActiveCell(cells[0]!);
setAidMemoireActive(false);
}, []);
const activeConstraints = useMemo(
() => [...new Set(normalized.puzzle.constraints.map((item) => item.type))],
[normalized.puzzle.constraints],
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);
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));
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));
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">
@@ -1119,7 +1486,15 @@ export function Workbench() {
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");
}}
@@ -1169,20 +1544,46 @@ export function Workbench() {
<section className="board-column" aria-label="Puzzle board">
<div className="board-toolbar">
<div className="toolbar-group">
<button type="button" disabled={!past.length} onClick={undo}>
<button
type="button"
disabled={!past.length || replayMoment !== undefined}
onClick={undo}
>
Undo
</button>
<button type="button" disabled={!future.length} onClick={redo}>
<button
type="button"
disabled={!future.length || replayMoment !== undefined}
onClick={redo}
>
Redo
</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>
{selection.length === 1
? cellLabel(selection[0]!, puzzle.size)
: `${String(selection.length)} cells selected`}
{replayMoment
? `Replay · ${replayMoment.label}`
: aidMemoireActive && aidMemoire.enabled
? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}`
: selection.length === 1
? cellLabel(selection[0]!, puzzle.size)
: `${String(selection.length)} cells selected`}
</span>
</div>
<div className={`board-surface${session.paused ? " is-paused" : ""}`}>
<div
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
>
<SudokuBoard
puzzle={normalized.puzzle}
values={session.values}
@@ -1190,16 +1591,19 @@ export function Workbench() {
centerMarks={session.centerMarks}
colors={session.colors}
candidates={candidateMasks}
selected={selectedSet}
selected={boardSelectedSet}
highlighted={highlightedCells}
conflicts={conflictCells}
activeCell={activeCell}
showCandidates={showCandidates}
candidateOverlay={
workspace === "helpers" ? candidateOverlay : undefined
}
onCellPointerDown={handlePointerDown}
onCellPointerEnter={handlePointerEnter}
onKeyDown={handleKeyDown}
/>
{session.paused && (
{session.paused && replayMoment === undefined && (
<button
type="button"
className="paused-cover"
@@ -1225,6 +1629,25 @@ export function Workbench() {
}
/>
)}
{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>
@@ -1247,13 +1670,37 @@ export function Workbench() {
</section>
<aside className="side-panel">
{workspace === "play" && (
{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">
Select one or several cells, then use the keypad or keyboard.
{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>
<NumberPad
@@ -1323,6 +1770,22 @@ export function Workbench() {
/>
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
@@ -1334,8 +1797,9 @@ export function Workbench() {
</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.
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>
)}
@@ -1401,9 +1865,13 @@ export function Workbench() {
{workspace === "helpers" && (
<HelpersWorkspace
key={puzzle.size}
size={puzzle.size}
puzzle={normalized.puzzle}
values={session.values}
selectedCells={selection}
candidateMasks={candidateMasks}
onCandidateOverlayChange={handleCandidateOverlayChange}
/>
)}
</aside>
@@ -1413,6 +1881,7 @@ export function Workbench() {
open={importOpen}
puzzle={puzzle}
session={session}
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
onClose={() => setImportOpen(false)}
onImport={(next, progress) => {
try {
@@ -1437,6 +1906,21 @@ export function Workbench() {
onExport={() => void exportLibrary()}
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>
);
}