feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+273
View File
@@ -0,0 +1,273 @@
import type { LogicalStep } from "../solver";
import {
GUIDED_HINT_STAGES,
guidedHintEffectItems,
guidedHintFocusSummary,
isGuidedHintStageRevealed,
logicalTechniqueDescription,
logicalTechniqueName,
nextGuidedHintStage,
type GuidedHintStage,
} from "./guidedHint";
const STAGE_LABELS: Record<GuidedHintStage, string> = {
focus: "Where to look",
technique: "Technique",
reasoning: "Reasoning",
preview: "Effects preview",
};
function revealButtonLabel(stage: GuidedHintStage): string | undefined {
const next = nextGuidedHintStage(stage);
if (next === "technique") return "Reveal technique";
if (next === "reasoning") return "Reveal reasoning";
if (next === "preview") return "Preview effects";
return undefined;
}
function stageStatus(stage: GuidedHintStage): string {
if (stage === "focus") return "Hint ready: where to look.";
if (stage === "technique") return "Technique revealed.";
if (stage === "reasoning") return "Reasoning revealed.";
return "Effects ready to preview and apply.";
}
export interface GuidedHintProps {
readonly size: number;
readonly step?: LogicalStep;
readonly stage: GuidedHintStage;
readonly busy?: boolean;
readonly error?: string;
readonly candidateTrackingActive?: boolean;
readonly autoMaintainPeerNotes: boolean;
readonly onRequestHint: () => void;
readonly onRevealNext: () => void;
readonly onApply: () => void;
readonly onDismiss: () => void;
readonly onFillLegalCandidates: () => void;
readonly onRemoveInvalidNotes: () => void;
readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
}
/**
* A controlled, progressively disclosed hint. The caller owns the hint step
* and stage so it can keep the board overlay and undo history in sync.
*/
export function GuidedHint({
size,
step,
stage,
busy = false,
error,
candidateTrackingActive = false,
autoMaintainPeerNotes,
onRequestHint,
onRevealNext,
onApply,
onDismiss,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
}: GuidedHintProps) {
const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
const effectItems =
step !== undefined && stage === "preview"
? guidedHintEffectItems(step, size)
: [];
return (
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
<div className="guided-hint__heading">
<div>
<p className="eyebrow">Guided solving</p>
<h3 id="guided-hint-title">One clue at a time</h3>
</div>
{step !== undefined && (
<button
type="button"
className="guided-hint__dismiss"
onClick={onDismiss}
disabled={busy}
>
Dismiss
</button>
)}
</div>
<p className="guided-hint__intro muted">
Reveal only as much help as you want. Nothing changes until you apply
the fully previewed step.
</p>
<p
className="guided-hint__status status-line"
role="status"
aria-live="polite"
aria-atomic="true"
>
{busy
? "Finding a logical next step locally…"
: error !== undefined
? "The hint could not be prepared."
: step === undefined
? "No guided hint is open."
: stageStatus(stage)}
</p>
{error !== undefined && (
<p className="guided-hint__error error-callout" role="alert">
{error}
</p>
)}
{step !== undefined && (
<>
<ol
className="guided-hint__progress"
aria-label="Hint reveal progress"
>
{GUIDED_HINT_STAGES.map((item) => {
const revealed = isGuidedHintStageRevealed(stage, item);
return (
<li
key={item}
className={revealed ? "is-revealed" : "is-concealed"}
aria-current={item === stage ? "step" : undefined}
>
{STAGE_LABELS[item]}
</li>
);
})}
</ol>
<div className="guided-hint__stage">
<section
className="guided-hint__focus"
aria-labelledby="hint-focus-title"
>
<p className="eyebrow" id="hint-focus-title">
Where to look
</p>
<p>{guidedHintFocusSummary(step, size)}</p>
</section>
{isGuidedHintStageRevealed(stage, "technique") && (
<section
className="guided-hint__technique"
aria-labelledby="hint-technique-title"
>
<p className="eyebrow" id="hint-technique-title">
Technique
</p>
<p>
<strong>{logicalTechniqueName(step.technique)}</strong>
</p>
<p className="muted">
{logicalTechniqueDescription(step.technique)}
</p>
</section>
)}
{isGuidedHintStageRevealed(stage, "reasoning") && (
<section
className="guided-hint__reasoning"
aria-labelledby="hint-reasoning-title"
>
<p className="eyebrow" id="hint-reasoning-title">
Why it works
</p>
<p>{step.explanation}</p>
</section>
)}
{stage === "preview" && (
<section
className="guided-hint__preview"
aria-labelledby="hint-preview-title"
>
<p className="eyebrow" id="hint-preview-title">
Effects preview
</p>
{effectItems.length > 0 ? (
<ul className="guided-hint__effect-list">
{effectItems.map((effect, index) => (
<li key={`${String(index)}-${effect}`}>{effect}</li>
))}
</ul>
) : (
<p>This deduction does not change the board.</p>
)}
{step.eliminations.length > 0 && !candidateTrackingActive && (
<p className="guided-hint__tracking-note muted">
Applying this elimination will start a complete legal
centre-candidate grid, then remove the previewed candidates.
</p>
)}
</section>
)}
</div>
<div className="guided-hint__actions action-row">
{nextLabel !== undefined && (
<button type="button" onClick={onRevealNext} disabled={busy}>
{nextLabel}
</button>
)}
{stage === "preview" && (
<button
type="button"
className="guided-hint__apply"
onClick={onApply}
disabled={busy || effectItems.length === 0}
>
Apply this step
</button>
)}
<button type="button" onClick={onRequestHint} disabled={busy}>
New hint
</button>
</div>
</>
)}
{step === undefined && (
<div className="guided-hint__actions action-row">
<button type="button" onClick={onRequestHint} disabled={busy}>
Get a guided hint
</button>
</div>
)}
<fieldset className="guided-hint__maintenance">
<legend>Candidate maintenance</legend>
<p className="muted" id="guided-candidate-status">
{candidateTrackingActive
? "The guided candidate grid is active."
: "Candidate tracking is currently inactive."}
</p>
<div
className="guided-hint__maintenance-actions action-row"
aria-describedby="guided-candidate-status"
>
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
Fill legal candidates
</button>
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
Remove invalid notes
</button>
</div>
<label className="guided-hint__auto-maintain check-row">
<input
type="checkbox"
checked={autoMaintainPeerNotes}
disabled={busy}
onChange={(event) =>
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
}
/>
Automatically remove peer notes after placing a digit
</label>
</fieldset>
</section>
);
}