274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|