feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user