Fencepost Duel
A fencepost-vs-panel comparison viz — the visual primitive behind every off-by-one demonstrator. The caller passes fences (number of posts) and panels (number of gaps between posts) and optionally an answer (the expected panel count). The component renders two stacked rows — posts on top, panels below — and flags the discrepancy when panels differs from answer.
Generic enough to cover any "compare the count of indexed objects against the count of intervals between them" interaction — prefix-sum sentinels, loop-boundary off-by-one, edge counts in a path, partition-count vs split-count duels — without baking in scoring, audio, or quiz phases. The caller composes prediction gates, captions, and feedback around the primitive.
Installation
npx shadcn@latest add https://craftbits.dev/r/fencepost-duel.jsonUsage
import { FencepostDuel } from "@craft-bits/core";
<FencepostDuel fences={5} panels={4} />Flag an off-by-one — panels differs from answer, so the mismatch badge surfaces:
<FencepostDuel fences={5} panels={5} answer={4} />Custom labels for non-fence analogies (e.g. range queries):
<FencepostDuel
fences={6}
panels={5}
answer={5}
fenceLabel="prefix slots"
panelLabel="range sums"
/>Hide the 0-indexed strip below the posts (useful when illustrating 1-indexed conventions):
<FencepostDuel fences={5} panels={4} showIndices={false} />Understanding the component
- Two rows, one relation. Posts render on the top row as upright bars; panels render below as horizontal bars. The classic fencepost relation is panels equals fences minus 1, but the component never enforces this — pass any counts so callers can show buggy intermediates.
- The answer is the contract. When
answeris provided and differs frompanels, the mismatch badge appears next to the panel count and both the count and every panel switch tomismatchTone. Dropanswerfor a read-only display. - Indices on demand.
showIndices(defaulttrue) draws a0..fences-1strip under each post so callers can demonstrate index conventions without re-rendering the posts themselves. - Tones are semantic.
tonecolours the rest state;mismatchTonecolours the discrepancy. Both fall back to--cb-*semantic vars so dark / light themes stay consistent. - Header / footer slots. Compose any narration, prediction gate, or hint above or below the rows via the
headerandfooterprops — the primitive stays scope-narrow. - Sanitised input. Negative or non-integer
fences/panelsare clamped to zero and floored — the component never throws on garbage input. - Reduced motion. Post and panel entry stagger collapses to instant under
prefers-reduced-motion: reduce. The mismatch badge still fades in but without scale.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
fences | number | required | Number of fence posts to render. Clamped to >= 0 and floored. |
panels | number | required | Number of panels (gaps) to render. Clamped to >= 0 and floored. |
answer | number | — | Expected panel count. When supplied and not equal to panels, the mismatch badge appears. |
fenceLabel | string | "fences" | Label rendered above the fence row. |
panelLabel | string | "panels" | Label rendered above the panel row. |
showIndices | boolean | true | Render the 0..fences-1 index strip below the posts. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight palette for posts, panels, and counts. |
mismatchTone | "default" | "accent" | "success" | "warning" | "error" | "warning" | Tone used to flag a panels !== answer mismatch. |
header | ReactNode | — | Content rendered above the fence row. |
footer | ReactNode | — | Content rendered below the panel row. |
transition | Transition | SPRINGS.smooth | Override post / panel transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The fence row and panel row are each wrapped in a
role="group"with anaria-labelledbypointing at the row label, so screen readers announce the row before reading its count. - Each row's count is announced via an explicit
aria-label(e.g. "5 fences", "4 panels") so the count is not orphan numeric text. - Each post / panel grid is
role="img"with anaria-labeldescribing the visual. - When
panelsdiffers fromanswer, the mismatch badge carries anaria-labelannouncing the delta so the discrepancy is audible without seeing the colour. - Tone is never the only signal — mismatches change both the count colour and every panel's fill / stroke, so colour-blind users see the visual delta.
- Reduced motion: post and panel entry stagger collapse to instant under
prefers-reduced-motion: reduce. - Empty states render as italic text instead of a silent gap so screen readers announce the absence.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/decision/FencepostDuel.tsx). The source was a multi-phase lesson component bundling a four-query prefix-sum duel between two robots, prediction gates, hint bars, a bug-line tap puzzle, magic-move code reveal, audio cues, and an act-by-act score rollup. The library extract keeps only the comparison primitive — fence-post row, panel row, optional mismatch badge against an expectedanswer, sanitised input, five tones — and lets the caller compose any acts, validation, narration, or sound on top via theheader/footerslots.