Off-By-One Diagnostic
A diagnostic primitive for off-by-one (and adjacent "almost right") bug lessons. The caller supplies a buggy code snippet, the expected and actual outputs as ReactNodes, and a list of cases — each case names a candidate bug mechanism and carries the feedback shown after the student picks it. Exactly one case is the true diagnosis (correctId); the rest are distractors with their own feedback strings.
Generic enough to cover any bug-attribution interaction — bound-off-by-one, swapped comparator, missed base case, miscounted iterations — without baking in scoring, audio, or phase orchestration. The panel owns selection, locks once the student picks the correct case, and emits onSelect(id, correct) so the parent can score, advance, or persist. Controlled (selectedId + onSelect) and uncontrolled (defaultSelectedId) on the Radix pattern.
function binarySearch(nums, target) {
let lo = 0;
let hi = nums.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (nums[mid] === target) return mid;
if (nums[mid] < target) lo = mid + 1;
else hi = mid;
}
return -1;
}
// Input: binarySearch([1, 2, 3, 4, 5], 5)
// Output: ?Installation
npx shadcn@latest add https://craftbits.dev/r/off-by-one-diagnostic.jsonUsage
import {
OffByOneDiagnosticPanel,
type OffByOneDiagnosticCase,
} from "@craft-bits/core";
const CASES: OffByOneDiagnosticCase[] = [
{
id: "hi-init",
label: "hi starts at nums.length instead of nums.length - 1",
feedback: "Correct. With [lo, hi) the last element stays reachable.",
},
{
id: "while-cond",
label: "while (lo < hi) should be while (lo <= hi)",
feedback: "No — lo === hi means the interval is empty.",
hint: "Think about what [lo, hi) means when lo === hi.",
},
];
<OffByOneDiagnosticPanel
code={code}
expected={<span>4</span>}
actual={<span>-1</span>}
cases={CASES}
correctId="hi-init"
/>Controlled — parent owns the selection so it can score, advance phases, or persist:
const [selectedId, setSelectedId] = useState<string | null>(null);
<OffByOneDiagnosticPanel
code={code}
expected={expected}
actual={actual}
cases={CASES}
correctId="hi-init"
selectedId={selectedId}
onSelect={(id, correct) => {
setSelectedId(id);
if (correct) advancePhase();
}}
/>Read-only review — render the correct diagnosis without letting the student re-pick:
<OffByOneDiagnosticPanel
code={code}
expected={expected}
actual={actual}
cases={CASES}
correctId="hi-init"
selectedId="hi-init"
editable={false}
tone="success"
/>Panel-level hint as a fallback when individual cases do not carry their own:
<OffByOneDiagnosticPanel
code={code}
expected={expected}
actual={actual}
cases={CASES}
correctId="hi-init"
hint="Try walking the half-open interval [lo, hi) by hand for one iteration."
/>Understanding the component
- Pick, then lock. Picking a case reveals its feedback and a tone-coded ring (success for the correct case, warning for a distractor). Once the correct case is picked, the panel locks — every other case fades and disables.
- Cases are radio options. The cases render as a
role="radiogroup"; each case is arole="radio"witharia-checkedreflecting selection. The first interactive press dictates the wrong / correct state and emitsonSelect(id, correct). - Controlled + uncontrolled.
selectedId+onSelectis the Radix controlled pattern.defaultSelectedIdlets the component own selection internally. If neither is provided, no case starts selected. - Hint is opt-in and per-case-first. When the student has picked a wrong case and either that case carries a
hintor the panel exposes a fallbackhint, a "Need a hint?" button appears below the feedback banner. Tapping it reveals the per-case hint (falling back to the panel-level one). - Outputs are caller-rendered.
expectedandactualaccept anyReactNode, so the same primitive renders raw values, arrays, ASCII tables, or fully fledged subcomponents. The panel only owns layout and tone — it never opines on shape. - Hit target. Every case row enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) regardless of label length.
- Reduced motion. Feedback enter, hint enter, and case ring transitions collapse to instant under
prefers-reduced-motion: reduce. The selection still locks; only the motion drops.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | required | The buggy code snippet rendered in a monospaced panel above the outputs. |
expected | ReactNode | required | What the variant should produce. Rendered in the success column. |
actual | ReactNode | required | What the buggy variant actually produced. Rendered in the error column. |
cases | OffByOneDiagnosticCase[] | required | Diagnostic candidates. Each carries id, label, feedback, optional hint. |
correctId | string | required | The id of the case that is the true bug mechanism. |
selectedId | string | null | — | Controlled selection. Pair with onSelect. |
defaultSelectedId | string | null | null | Uncontrolled initial selection. |
onSelect | (id: string, correct: boolean) => void | — | Fires every time the student picks (or re-picks) a case. |
actualLabel | string | "Produced" | Title rendered above the actual-output column. |
expectedLabel | string | "Expected" | Title rendered above the expected-output column. |
prompt | string | "What is the bug?" | Prompt rendered above the case list. |
hint | string | — | Panel-level fallback hint, shown when no per-case hint is set. |
editable | boolean | true | When false, the cases become non-interactive. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Tone for the selected-case ring and hint banner. |
header | ReactNode | — | Content rendered above the code panel. |
footer | ReactNode | — | Content rendered below the cases. |
transition | Transition | SPRINGS.smooth | Override case / feedback transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The case list is a
role="radiogroup"labelled by the prompt; each case is arole="radio"button witharia-checkedreflecting selection. - Every case is keyboard activated — Tab to focus, Space or Enter to pick — and renders a visible focus ring through
focus-visible:ring-cb-accent. - All interactive targets enforce a 44 × 44px minimum hit area (per WCAG 2.5.8 AAA) regardless of label width.
- The code panel carries an
aria-label; the output columns carry anaria-labelthat announces their role (produced / expected). - Feedback and hint banners use
role="status"+aria-live="polite"so the student's pick announces without stealing focus. - Cases expose
data-state(rest/selected/correct/wrong) so consumer apps can hook custom styles or assistive tooling. - Tone is never the only signal — selected, correct, and wrong states layer fill, ring, and indicator-dot changes so colour-blind users see the distinction.
- Motion respects
prefers-reduced-motion: reduce— feedback fade-in, hint fade-in, and case ring transitions collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/decision/OffByOneDiagnosticPanel.tsx). The source was a generic per-variant trace panel that owned multi-variant tabs, a Shiki-highlightedCodeTrace, aLessonButton.Quizrow, per-distractor hint routing, audio cues (plot/correct/reject), and arenderStatusBannerslot. The library extract drops the tab strip and the audio side-effects, simplifies the surface to a single buggy snippet plus expected / actual columns, and keeps the per-case feedback + optional hint reveal that made the original lesson primitive valuable. Consumers compose multi-variant flows on top by stacking panels or owning their own tab strip.