feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
"Sudoku Tools encountered an unrecoverable interface error",
error,
info,
);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<main className="fatal-error" role="alert">
<h1>Sudoku Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button
type="button"
className="primary-button"
onClick={() => globalThis.location.reload()}
>
Reload application
</button>
</main>
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
interface ConstraintEditorProps {
puzzle: PuzzleDefinition;
selection: readonly number[];
onChange: (puzzle: PuzzleDefinition) => void;
onNewGrid: (size: number) => void;
onCheck: () => void;
onGenerate: () => void;
busy: boolean;
}
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
switch (constraint.type) {
case "diagonal":
return `${constraint.direction} diagonal`;
case "anti-knight":
return "anti-knight";
case "anti-king":
return "anti-king";
case "non-consecutive":
return "non-consecutive";
case "killer-cage":
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
case "thermo":
case "renban":
case "palindrome":
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
case "arrow":
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
case "kropki":
return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`;
case "xv":
return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}${cell(constraint.b)}`;
case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
}
}
export function ConstraintEditor({
puzzle,
selection,
onChange,
onNewGrid,
onCheck,
onGenerate,
busy,
}: ConstraintEditorProps) {
const [cageSum, setCageSum] = useState(10);
const [region, setRegion] = useState(1);
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
onChange({ ...puzzle, constraints: [...constraints, constraint] });
const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count;
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
...puzzle,
constraints: exists
? constraints.filter((constraint) => constraint.type !== type)
: [...constraints, { type }],
});
};
return (
<div className="setter-panel stack">
<section className="panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Puzzle definition</p>
<h2>Set a puzzle</h2>
</div>
<label className="compact-field">
Grid
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
<option value="9">9 × 9</option>
<option value="12">12 × 12</option>
<option value="16">16 × 16</option>
</select>
</label>
</div>
<div className="field-grid">
<label>
Title
<input
value={puzzle.title ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, title: event.target.value })
}
/>
</label>
<label>
Setter
<input
value={puzzle.author ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, author: event.target.value })
}
/>
</label>
</div>
<label>
Rules
<textarea
rows={3}
maxLength={16_384}
value={puzzle.rules ?? ""}
onChange={(event) =>
onChange({ ...puzzle, rules: event.target.value })
}
/>
</label>
</section>
<section className="panel-section">
<p className="eyebrow">Selected cells</p>
<h3>
{selection.length
? `${String(selection.length)} selected`
: "Select cells on the grid"}
</h3>
<p className="muted">
Selection order defines lines and pair direction. Shift-click or drag
to build a selection.
</p>
<div className="inline-fields">
<label className="compact-field">
Cage sum
<input
type="number"
min="1"
max={puzzle.size * puzzle.size}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
onClick={() =>
append({ type: "killer-cage", cells: selection, sum: cageSum })
}
>
Add cage
</button>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "thermo", cells: selection })}
>
Thermo
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "renban", cells: selection })}
>
Renban
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "palindrome", cells: selection })}
>
Palindrome
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() =>
append({
type: "arrow",
bulb: [selection[0]!],
line: selection.slice(1),
})
}
>
Arrow
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "white",
})
}
>
White dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "black",
})
}
>
Black dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 5,
})
}
>
V pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 10,
})
}
>
X pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[0]!,
greater: selection[1]!,
})
}
>
First &lt; second
</button>
</div>
<div className="inline-fields">
<label className="compact-field">
Region
<input
type="number"
min="1"
max={puzzle.size}
value={region}
onChange={(event) => setRegion(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || region < 1 || region > puzzle.size}
onClick={() => {
const regions = [...(puzzle.regions ?? [])];
for (const cell of selection) regions[cell] = region - 1;
onChange({ ...puzzle, regions });
}}
>
Paint region
</button>
</div>
</section>
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
constraint.type === "diagonal" &&
constraint.direction === direction,
);
return (
<button
key={direction}
type="button"
className={active ? "is-active" : ""}
aria-pressed={active}
onClick={() =>
onChange({
...puzzle,
constraints: active
? constraints.filter(
(constraint) =>
constraint.type !== "diagonal" ||
constraint.direction !== direction,
)
: [...constraints, { type: "diagonal", direction }],
})
}
>
{direction} diagonal
</button>
);
})}
</div>
</section>
{constraints.length > 0 && (
<section className="panel-section">
<p className="eyebrow">Constraints</p>
<ul className="constraint-list">
{constraints.map((constraint, index) => (
<li key={`${constraint.type}-${String(index)}`}>
<span>{describeConstraint(constraint, puzzle.size)}</span>
<button
className="text-button danger"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</li>
))}
</ul>
</section>
)}
<section className="panel-section action-row">
<button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; uniqueness
</button>
<button
type="button"
onClick={onGenerate}
disabled={busy || puzzle.size > 9}
>
Generate classic
</button>
</section>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { Modal } from "./Modal";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
<div className="help-grid">
<section>
<h3>Four complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Solve</strong> explains logical steps and can
verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board.
</p>
</section>
<section>
<h3>Keyboard</h3>
<dl className="shortcut-list">
<div>
<dt>Arrow keys</dt>
<dd>Move the active cell</dd>
</div>
<div>
<dt>Shift + arrows</dt>
<dd>Extend the selection</dd>
</div>
<div>
<dt>19 / AG</dt>
<dd>Enter the selected symbol</dd>
</div>
<div>
<dt>Z / X / C / V</dt>
<dd>Value, corner, centre or colour mode</dd>
</div>
<div>
<dt>Backspace</dt>
<dd>Erase in the current mode</dd>
</div>
<div>
<dt>Ctrl/ + Z/Y</dt>
<dd>Undo or redo</dd>
</div>
</dl>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
Candidate legality is computed independently from handwritten notes.
Logical deductions report their premises, affected houses,
placements and eliminations. Exact search is separately labelled; it
proves feasibility or uniqueness but is not presented as a human
explanation.
</p>
</section>
<section>
<h3>Import and privacy</h3>
<p>
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments and supported f-puzzles data
are decoded locally. SudokuPad short IDs need its server and are
intentionally rejected. Review an export before sharing: titles,
authors, rules, solutions and progress may be included.
</p>
</section>
</div>
</Modal>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { useMemo, useState } from "react";
import {
analyzeKillerCage,
calculateResidual,
relationPairs,
type RelationSpec,
} from "../helpers";
import { maskValues, symbolFor } from "../state/session";
function digits(text: string) {
return [
...new Set(
(text.match(/\d+|[A-P]/giu) ?? []).map((token) =>
/^\d+$/u.test(token)
? Number(token)
: token.toUpperCase().charCodeAt(0) - 55,
),
),
];
}
function numbers(text: string) {
return (text.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
}
export function HelpersWorkspace({
size,
selectedCells,
candidateMasks,
}: {
size: number;
selectedCells: readonly number[];
candidateMasks: readonly number[];
}) {
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
"killer",
);
const [cellCount, setCellCount] = useState(2);
const [sum, setSum] = useState(10);
const [allowed, setAllowed] = useState("");
const [required, setRequired] = useState("");
const [excluded, setExcluded] = useState("");
const [allowRepeats, setAllowRepeats] = useState(false);
const [useBoardCandidates, setUseBoardCandidates] = useState(false);
const [knownParts, setKnownParts] = useState("15, 12");
const [unknownCount, setUnknownCount] = useState(2);
const [relation, setRelation] = useState("white");
const [firstCandidates, setFirstCandidates] = useState("");
const [secondCandidates, setSecondCandidates] = useState("");
const effectiveCount =
useBoardCandidates && selectedCells.length > 0
? selectedCells.length
: cellCount;
const killer = useMemo(() => {
try {
const candidates =
useBoardCandidates && selectedCells.length > 0
? selectedCells.map((cell) =>
maskValues(candidateMasks[cell] ?? 0, size),
)
: undefined;
return {
result: analyzeKillerCage({
size,
cellCount: effectiveCount,
sum,
allowRepeats,
...(allowed.trim() ? { allowedDigits: digits(allowed) } : {}),
...(required.trim() ? { requiredDigits: digits(required) } : {}),
...(excluded.trim() ? { excludedDigits: digits(excluded) } : {}),
...(candidates === undefined ? {} : { candidates }),
}),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid helper input.",
};
}
}, [
allowRepeats,
allowed,
candidateMasks,
effectiveCount,
excluded,
required,
selectedCells,
size,
sum,
useBoardCandidates,
]);
const residual = useMemo(() => {
try {
return {
result: calculateResidual({
size,
knownSums: numbers(knownParts),
unknownCount,
allowRepeats,
}),
};
} catch (error) {
return {
error:
error instanceof Error ? error.message : "Invalid residual input.",
};
}
}, [allowRepeats, knownParts, size, unknownCount]);
const pairs = useMemo(() => {
try {
const pairSpec: RelationSpec =
relation === "white"
? { type: "kropki", kind: "white" }
: relation === "black"
? { type: "kropki", kind: "black" }
: relation === "v"
? { type: "xv", total: 5 }
: relation === "x"
? { type: "xv", total: 10 }
: relation === "less"
? { type: "inequality", relation: "<" }
: { type: "inequality", relation: ">" };
return {
result: relationPairs(
pairSpec,
size,
firstCandidates.trim() ? digits(firstCandidates) : undefined,
secondCandidates.trim() ? digits(secondCandidates) : undefined,
),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid pair input.",
};
}
}, [firstCandidates, relation, secondCandidates, size]);
return (
<div className="helpers-workspace stack">
<div>
<p className="eyebrow">Focused calculators</p>
<h2>Sudoku helpers</h2>
<p className="muted">
Answer a local question without changing the puzzle. Values are
bounded to the current {size}×{size} symbol set.
</p>
</div>
<div className="subtabs" role="tablist" aria-label="Helper">
{(
[
["killer", "Killer combinations"],
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
["relations", "Pair relations"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={helper === id}
className={helper === id ? "is-active" : ""}
onClick={() => setHelper(id)}
>
{label}
</button>
))}
</div>
{helper === "killer" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Cells
<input
type="number"
min="1"
max={size}
value={cellCount}
disabled={useBoardCandidates && selectedCells.length > 0}
onChange={(event) => setCellCount(Number(event.target.value))}
/>
</label>
<label>
Sum
<input
type="number"
min="1"
max={size * size}
value={sum}
onChange={(event) => setSum(Number(event.target.value))}
/>
</label>
<label>
Allowed digits
<input
value={allowed}
placeholder="all"
onChange={(event) => setAllowed(event.target.value)}
/>
</label>
<label>
Must include
<input
value={required}
placeholder="e.g. 1, 7"
onChange={(event) => setRequired(event.target.value)}
/>
</label>
<label>
Exclude
<input
value={excluded}
placeholder="e.g. 5"
onChange={(event) => setExcluded(event.target.value)}
/>
</label>
<label className="check-row">
<input
type="checkbox"
checked={allowRepeats}
onChange={(event) => setAllowRepeats(event.target.checked)}
/>
Allow repeated digits
</label>
<label className="check-row">
<input
type="checkbox"
checked={useBoardCandidates}
disabled={selectedCells.length === 0}
onChange={(event) =>
setUseBoardCandidates(event.target.checked)
}
/>
Use {selectedCells.length || "selected"} board cell
{selectedCells.length === 1 ? "" : "s"} and candidates
</label>
</div>
{killer.error ? (
<p className="error-callout" role="alert">
{killer.error}
</p>
) : killer.result ? (
<div className="helper-result">
<div className="metric-row">
<span>
<strong>{killer.result.combinations.length}</strong>{" "}
combinations
</span>
<span>
Possible{" "}
<strong>
{killer.result.possibleDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
<span>
Necessary{" "}
<strong>
{killer.result.necessaryDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
</div>
{killer.result.possibleByCell.some(
(entry) => entry.length > 0,
) && (
<ol className="cell-possibilities">
{killer.result.possibleByCell.map((entry, index) => (
<li key={index}>
Cell {index + 1}:{" "}
{entry.map((value) => symbolFor(value, size)).join(" ") ||
"—"}
</li>
))}
</ol>
)}
<div className="combination-cloud" aria-label="Combinations">
{killer.result.combinations.slice(0, 300).map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
{(killer.result.combinations.length > 300 ||
killer.result.truncated) && (
<p className="muted">
The visible list is bounded; refine the filters to inspect
fewer results.
</p>
)}
</div>
) : null}
</section>
)}
{helper === "residual" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Accounted values or cage sums
<input
value={knownParts}
onChange={(event) => setKnownParts(event.target.value)}
/>
</label>
<label>
Unaccounted cells
<input
type="number"
min="0"
max={size}
value={unknownCount}
onChange={(event) =>
setUnknownCount(Number(event.target.value))
}
/>
</label>
</div>
{residual.error ? (
<p className="error-callout" role="alert">
{residual.error}
</p>
) : residual.result ? (
<div className="helper-result">
<p className="residual-equation">
{residual.result.total} {residual.result.accounted} ={" "}
<strong>{residual.result.residual}</strong>
</p>
<p>
{residual.result.analysis?.combinations.length ?? 0} possible
digit combinations for {unknownCount} residual cell
{unknownCount === 1 ? "" : "s"}.
</p>
<div className="combination-cloud">
{residual.result.analysis?.combinations
.slice(0, 300)
.map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
</div>
) : null}
</section>
)}
{helper === "relations" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Relation
<select
value={relation}
onChange={(event) => setRelation(event.target.value)}
>
<option value="white">White Kropki · difference 1</option>
<option value="black">Black Kropki · ratio 1:2</option>
<option value="v">V · sum 5</option>
<option value="x">X · sum 10</option>
<option value="less">First &lt; second</option>
<option value="greater">First &gt; second</option>
</select>
</label>
<label>
First-cell candidates
<input
value={firstCandidates}
placeholder="all"
onChange={(event) => setFirstCandidates(event.target.value)}
/>
</label>
<label>
Second-cell candidates
<input
value={secondCandidates}
placeholder="all"
onChange={(event) => setSecondCandidates(event.target.value)}
/>
</label>
</div>
{pairs.error ? (
<p className="error-callout" role="alert">
{pairs.error}
</p>
) : (
<div className="pair-table">
{pairs.result?.map(([first, second]) => (
<code key={`${String(first)}-${String(second)}`}>
{symbolFor(first, size)} {symbolFor(second, size)}
</code>
))}
</div>
)}
</section>
)}
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import { useMemo, useRef, useState } from "react";
import type { PuzzleDefinition } from "../domain/types";
import {
decodePuzzleHash,
encodePuzzleHash,
exportFpuzzlesJson,
exportFpuzzlesUrl,
fromDomainPuzzle,
importFpuzzles,
parseFpuzzles,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
import { maskValues } from "../state/session";
import { Modal } from "./Modal";
function parseImport(input: string): SudokuDocument {
const trimmed = input.trim();
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
return decodePuzzleHash(trimmed);
if (
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
trimmed,
)
)
return importFpuzzles(trimmed);
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
if (trimmed.startsWith("{")) {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
return parseSudokuDocument(trimmed);
return parseFpuzzles(parsed);
}
return parsePlainGrid(trimmed);
}
function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
return {
...base,
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,
};
}
function download(name: string, contents: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
progress?: Pick<
SudokuDocument,
| "values"
| "cornerMarks"
| "centerMarks"
| "candidates"
| "colors"
| "elapsedMs"
>,
) => void;
}
export function ImportExportDialog({
open,
puzzle,
session,
onClose,
onImport,
}: ImportExportDialogProps) {
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session)
: fromDomainPuzzle(puzzle),
[includeProgress, puzzle, session],
);
const copy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
setFeedback(`${label} copied.`);
} catch {
setFeedback(
"Clipboard access was denied; use the download option instead.",
);
}
};
return (
<Modal open={open} title="Import and export" onClose={onClose} wide>
<div className="import-export-grid">
<section className="stack">
<div>
<p className="eyebrow">Import</p>
<h3>Paste puzzle data</h3>
</div>
<p className="muted">
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
short IDs are never fetched.
</p>
<textarea
rows={12}
value={input}
maxLength={1_048_576}
spellCheck={false}
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => setInput(event.target.value)}
/>
<div className="action-row">
<button type="button" onClick={() => fileRef.current?.click()}>
Choose puzzle file
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,text/plain,.json,.txt,.sdk"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file === undefined) return;
if (file.size > 1_048_576) {
setFeedback("Puzzle files are limited to 1 MiB.");
return;
}
void file
.text()
.then((contents) => {
setInput(contents);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
})
.catch(() =>
setFeedback("The puzzle file could not be read."),
);
}}
/>
<button
type="button"
disabled={!input.trim()}
onClick={() => {
try {
const parsed = parseImport(input);
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
});
setFeedback("Puzzle imported locally.");
onClose();
} catch (error) {
setFeedback(
error instanceof Error
? error.message
: "The puzzle could not be imported.",
);
}
}}
>
Import locally
</button>
</div>
</section>
<section className="stack">
<div>
<p className="eyebrow">Export</p>
<h3>Choose a portable representation</h3>
</div>
<label className="check-row">
<input
type="checkbox"
checked={includeProgress}
onChange={(event) => setIncludeProgress(event.target.checked)}
/>
Include values, notes, colours and elapsed time
</label>
<div className="export-actions">
<button
type="button"
onClick={() =>
download(
"sudoku-tools-puzzle.json",
serializeSudokuDocument(documentValue, true),
"application/json",
)
}
>
Download project JSON
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.txt",
serializePlainGrid(
documentValue,
includeProgress ? "values" : "givens",
),
"text/plain",
)
}
>
Download grid text
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.fpuzzles.json",
exportFpuzzlesJson(documentValue, true),
"application/json",
)
}
>
Download f-puzzles JSON
</button>
<button
type="button"
onClick={() =>
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
}
>
Copy f-puzzles URL
</button>
<button
type="button"
onClick={() => {
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
void copy(share, "Local share URL");
}}
>
Copy local share URL
</button>
</div>
<p className="callout">
Share links are self-contained. They may expose title, setter,
rules, solution progress and notes to anyone receiving the URL.
</p>
</section>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
</Modal>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useRef } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
function date(value: number) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function LibraryDialog({
open,
summaries,
mode,
busy,
feedback,
onClose,
onSave,
onOpen,
onDelete,
onClear,
onExport,
onImport,
}: {
open: boolean;
summaries: readonly SudokuProjectSummary[];
mode: "indexeddb" | "memory";
busy: boolean;
feedback?: string;
onClose: () => void;
onSave: () => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
Export important puzzles before clearing browser data.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onSave}>
Save current
</button>
<button
type="button"
disabled={busy || summaries.length === 0}
onClick={onExport}
>
Export library
</button>
<button
type="button"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
Import library
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = "";
}}
/>
</div>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
<li key={item.id}>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</li>
))}
</ul>
)}
{summaries.length > 0 && (
<div className="danger-zone">
<button type="button" className="danger" onClick={onClear}>
Clear local library
</button>
</div>
)}
</Modal>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
interface ModalProps {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
wide?: boolean;
}
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
className={`modal${wide ? " modal--wide" : ""}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === dialogRef.current) onClose();
}}
>
<div className="modal__surface">
<header className="modal__header">
<h2 id={titleId}>{title}</h2>
<button className="icon-button" type="button" onClick={onClose}>
<span aria-hidden="true">×</span>
<span className="sr-only">Close</span>
</button>
</header>
<div className="modal__body">{children}</div>
</div>
</dialog>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
{ mode: "corner", label: "Corner", key: "X" },
{ mode: "center", label: "Centre", key: "C" },
{ mode: "color", label: "Colour", key: "V" },
];
export function NumberPad({
size,
mode,
onMode,
onValue,
onErase,
}: {
size: number;
mode: EntryMode;
onMode: (mode: EntryMode) => void;
onValue: (value: number) => void;
onErase: () => void;
}) {
return (
<div className="number-pad">
<div className="mode-switcher" role="group" aria-label="Entry mode">
{modes.map((item) => (
<button
key={item.mode}
type="button"
className={mode === item.mode ? "is-active" : ""}
aria-pressed={mode === item.mode}
title={`${item.label} mode (${item.key})`}
onClick={() => onMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div
className={`digit-pad${size > 9 ? " digit-pad--wide" : ""}`}
role="group"
aria-label={mode === "color" ? "Colours" : "Digits"}
>
{Array.from({ length: mode === "color" ? 8 : size }, (_, index) => {
const value = index + 1;
return (
<button
key={value}
type="button"
className={
mode === "color" ? `color-choice color-${String(value)}` : ""
}
onClick={() => onValue(value)}
>
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
</>
) : (
symbolFor(value, size)
)}
</button>
);
})}
<button type="button" className="erase-key" onClick={onErase}>
Erase
</button>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import type {
ExactSolveResult,
LogicalSolveResult,
LogicalStep,
} from "../solver";
import { symbolFor } from "../state/session";
function cellName(cell: number, size: number) {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function techniqueName(value: string) {
return value
.split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
function StepCard({
step,
index,
size,
active,
onSelect,
}: {
step: LogicalStep;
index: number;
size: number;
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={`solve-step${active ? " is-active" : ""}`}
onClick={onSelect}
>
<span className="step-number">{index + 1}</span>
<span>
<strong>{techniqueName(step.technique)}</strong>
<small>{step.explanation}</small>
{(step.placements.length > 0 || step.eliminations.length > 0) && (
<span className="step-effects">
{step.placements
.map(
(placement) =>
`${cellName(placement.cell, size)}=${symbolFor(placement.value, size)}`,
)
.join(", ")}
{step.placements.length > 0 && step.eliminations.length > 0
? " · "
: ""}
{step.eliminations
.map(
(elimination) =>
`${cellName(elimination.cell, size)} ${elimination.values.map((value) => symbolFor(value, size)).join("")}`,
)
.join(", ")}
</span>
)}
</span>
</button>
);
}
export function SolveWorkspace({
size,
busy,
logical,
exact,
error,
onLogical,
onExact,
onApplyValues,
onFocusCells,
}: {
size: number;
busy: boolean;
logical?: LogicalSolveResult;
exact?: ExactSolveResult;
error?: string;
onLogical: () => void;
onExact: () => void;
onApplyValues: (values: readonly number[]) => void;
onFocusCells: (cells: readonly number[]) => void;
}) {
const [stepSelection, setStepSelection] = useState<{
logical?: LogicalSolveResult;
index: number;
}>({ index: 0 });
const activeStep =
stepSelection.logical === logical ? stepSelection.index : 0;
useEffect(() => {
const step = logical?.steps[activeStep];
onFocusCells(step?.focusCells ?? []);
}, [activeStep, logical, onFocusCells]);
return (
<div className="solve-workspace stack">
<div>
<p className="eyebrow">Explainable analysis</p>
<h2>Solve and verify</h2>
<p className="muted">
Human logic and exact search are deliberately separate. A uniqueness
proof is not described as a human deduction.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onLogical}>
Build logical solve path
</button>
<button type="button" disabled={busy} onClick={onExact}>
Count solutions (up to 2)
</button>
</div>
{busy && (
<p className="status-line" role="status">
Analysing in a local worker
</p>
)}
{error && (
<p className="error-callout" role="alert">
{error}
</p>
)}
{exact && (
<section className="analysis-summary">
<p className="eyebrow">Exact search</p>
<div className="metric-row">
<span>
<strong>{exact.count}</strong> solution
{exact.count === 1 ? "" : "s"} found
</span>
<span>
<strong>{exact.nodes.toLocaleString()}</strong> nodes
</span>
<span>
<strong>{exact.elapsedMs.toLocaleString()}</strong> ms
</span>
</div>
<p>
{exact.count === 0
? "No completion satisfies every supported rule."
: exact.count === 1 && !exact.truncated
? "The current puzzle has exactly one solution within the configured search bounds."
: exact.count >= 2
? "At least two solutions exist; the puzzle is not unique."
: "Search stopped at a safety limit, so uniqueness is not established."}
</p>
{exact.solutions[0] && (
<button
type="button"
onClick={() => onApplyValues(exact.solutions[0]!)}
>
Show first exact solution on board
</button>
)}
</section>
)}
{logical && (
<section className="logical-results">
<div className="section-heading">
<div>
<p className="eyebrow">Logical path</p>
<h3>
{logical.steps.length} explained step
{logical.steps.length === 1 ? "" : "s"}
</h3>
</div>
<span className={`status-pill status-${logical.status}`}>
{logical.status}
</span>
</div>
<p>
{logical.status === "solved"
? "The configured human techniques complete this puzzle."
: logical.status === "stuck"
? "No supported next deduction was found. Exact search may still solve it."
: logical.status === "invalid"
? "A contradiction was reached."
: "The logical-step safety limit was reached."}
</p>
{logical.status === "solved" && (
<button type="button" onClick={() => onApplyValues(logical.values)}>
Show logical result on board
</button>
)}
<div className="solve-steps">
{logical.steps.map((step, index) => (
<StepCard
key={`${step.technique}-${String(index)}`}
step={step}
index={index}
size={size}
active={activeStep === index}
onSelect={() => setStepSelection({ logical, index })}
/>
))}
</div>
</section>
)}
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import { maskValues, symbolFor } from "../state/session";
interface SudokuBoardProps {
puzzle: NormalizedPuzzle;
values: readonly number[];
cornerMarks?: readonly number[];
centerMarks?: readonly number[];
colors?: readonly number[];
candidates?: readonly number[];
selected: ReadonlySet<number>;
conflicts?: ReadonlySet<number>;
activeCell: number;
showCandidates?: boolean;
onCellPointerDown: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onCellPointerEnter: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
}
function point(size: number, cell: number) {
return {
x: (cell % size) + 0.5,
y: Math.floor(cell / size) + 0.5,
};
}
function polyline(size: number, cells: readonly number[]) {
return cells
.map((cell) => {
const { x, y } = point(size, cell);
return `${x},${y}`;
})
.join(" ");
}
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
const commands: string[] = [];
for (const cell of cells) {
const row = Math.floor(cell / size);
const column = cell % size;
const x0 = column + inset;
const x1 = column + 1 - inset;
const y0 = row + inset;
const y1 = row + 1 - inset;
if (!cells.has(cell - size) || row === 0)
commands.push(`M${x0} ${y0}H${x1}`);
if (!cells.has(cell + size) || row === size - 1)
commands.push(`M${x0} ${y1}H${x1}`);
if (!cells.has(cell - 1) || column === 0)
commands.push(`M${x0} ${y0}V${y1}`);
if (!cells.has(cell + 1) || column === size - 1)
commands.push(`M${x1} ${y0}V${y1}`);
}
return commands.join(" ");
}
function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set<number>());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => {
if (constraint.type === "diagonal") {
return (
<line
key={index}
className="constraint-diagonal"
x1={constraint.direction === "main" ? 0.12 : size - 0.12}
y1={0.12}
x2={constraint.direction === "main" ? size - 0.12 : 0.12}
y2={size - 0.12}
/>
);
}
if (constraint.type === "killer-cage") {
const cells = new Set(constraint.cells);
const first = Math.min(...constraint.cells);
const position = point(size, first);
return (
<g key={index} className="constraint-cage">
<path d={boundaryPath(size, cells, 0.09)} />
<text x={position.x - 0.34} y={position.y - 0.27}>
{constraint.sum}
</text>
</g>
);
}
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!);
return (
<g key={index} className="constraint-thermo">
<polyline points={polyline(size, constraint.cells)} />
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
</g>
);
}
if (constraint.type === "arrow") {
const bulb = point(size, constraint.bulb[0]!);
const end = point(size, constraint.line.at(-1)!);
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
return (
<g key={index} className="constraint-arrow">
<circle cx={bulb.x} cy={bulb.y} r="0.32" />
<polyline points={polyline(size, connectedLine)} />
<circle cx={end.x} cy={end.y} r="0.08" />
</g>
);
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
return (
<g key={index} className={`constraint-${constraint.type}`}>
<polyline points={polyline(size, constraint.cells)} />
{constraint.type === "palindrome" &&
constraint.cells.map((cell) => {
const p = point(size, cell);
return <circle key={cell} cx={p.x} cy={p.y} r="0.14" />;
})}
</g>
);
}
if (
constraint.type === "kropki" ||
constraint.type === "xv" ||
constraint.type === "inequality"
) {
const a = point(
size,
constraint.type === "inequality" ? constraint.lesser : constraint.a,
);
const b = point(
size,
constraint.type === "inequality" ? constraint.greater : constraint.b,
);
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
if (constraint.type === "kropki")
return (
<circle
key={index}
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
cx={x}
cy={y}
r="0.115"
/>
);
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return (
<text
key={index}
className={`constraint-label constraint-label--${constraint.type}`}
x={x}
y={y}
transform={
constraint.type === "inequality"
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
: undefined
}
>
{constraint.type === "xv"
? constraint.total === 5
? "V"
: "X"
: "<"}
</text>
);
}
return null;
};
return (
<svg
className="constraint-layer"
viewBox={`0 0 ${String(size)} ${String(size)}`}
aria-hidden="true"
>
<g className="region-boundaries">
{regionCells.map((cells, index) => (
<path key={index} d={boundaryPath(size, cells, 0)} />
))}
</g>
{puzzle.constraints.map(renderConstraint)}
</svg>
);
}
export function SudokuBoard({
puzzle,
values,
cornerMarks = [],
centerMarks = [],
colors = [],
candidates = [],
selected,
conflicts = new Set<number>(),
activeCell,
showCandidates,
onCellPointerDown,
onCellPointerEnter,
onKeyDown,
}: SudokuBoardProps) {
const { size } = puzzle;
return (
<div
className="sudoku-board-frame"
style={{ "--sudoku-size": size } as CSSProperties}
>
<div
className="sudoku-board"
role="grid"
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
onKeyDown={onKeyDown}
>
{values.map((value, cell) => {
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
const center = maskValues(
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
size,
);
const row = Math.floor(cell / size) + 1;
const column = (cell % size) + 1;
return (
<button
key={cell}
type="button"
role="gridcell"
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-selected={selected.has(cell)}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
</div>
<ConstraintLayer puzzle={puzzle} />
</div>
);
}
File diff suppressed because it is too large Load Diff