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
+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>
);
}