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: ?
Produced
-1
Expected
4
What is the bug?
Customize
Highlight
1
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/off-by-one-diagnostic.json

Usage

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

  1. 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.
  2. Cases are radio options. The cases render as a role="radiogroup"; each case is a role="radio" with aria-checked reflecting selection. The first interactive press dictates the wrong / correct state and emits onSelect(id, correct).
  3. Controlled + uncontrolled. selectedId + onSelect is the Radix controlled pattern. defaultSelectedId lets the component own selection internally. If neither is provided, no case starts selected.
  4. Hint is opt-in and per-case-first. When the student has picked a wrong case and either that case carries a hint or the panel exposes a fallback hint, a "Need a hint?" button appears below the feedback banner. Tapping it reveals the per-case hint (falling back to the panel-level one).
  5. Outputs are caller-rendered. expected and actual accept any ReactNode, 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.
  6. Hit target. Every case row enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) regardless of label length.
  7. 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

PropTypeDefaultDescription
codestringrequiredThe buggy code snippet rendered in a monospaced panel above the outputs.
expectedReactNoderequiredWhat the variant should produce. Rendered in the success column.
actualReactNoderequiredWhat the buggy variant actually produced. Rendered in the error column.
casesOffByOneDiagnosticCase[]requiredDiagnostic candidates. Each carries id, label, feedback, optional hint.
correctIdstringrequiredThe id of the case that is the true bug mechanism.
selectedIdstring | nullControlled selection. Pair with onSelect.
defaultSelectedIdstring | nullnullUncontrolled initial selection.
onSelect(id: string, correct: boolean) => voidFires every time the student picks (or re-picks) a case.
actualLabelstring"Produced"Title rendered above the actual-output column.
expectedLabelstring"Expected"Title rendered above the expected-output column.
promptstring"What is the bug?"Prompt rendered above the case list.
hintstringPanel-level fallback hint, shown when no per-case hint is set.
editablebooleantrueWhen false, the cases become non-interactive.
tone"default" | "accent" | "success" | "warning" | "error""accent"Tone for the selected-case ring and hint banner.
headerReactNodeContent rendered above the code panel.
footerReactNodeContent rendered below the cases.
transitionTransitionSPRINGS.smoothOverride case / feedback transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The case list is a role="radiogroup" labelled by the prompt; each case is a role="radio" button with aria-checked reflecting 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 an aria-label that 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-highlighted CodeTrace, a LessonButton.Quiz row, per-distractor hint routing, audio cues (plot / correct / reject), and a renderStatusBanner slot. 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.