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.

fences5
panels4

Installation

npx shadcn@latest add https://craftbits.dev/r/fencepost-duel.json

Usage

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

  1. 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.
  2. The answer is the contract. When answer is provided and differs from panels, the mismatch badge appears next to the panel count and both the count and every panel switch to mismatchTone. Drop answer for a read-only display.
  3. Indices on demand. showIndices (default true) draws a 0..fences-1 strip under each post so callers can demonstrate index conventions without re-rendering the posts themselves.
  4. Tones are semantic. tone colours the rest state; mismatchTone colours the discrepancy. Both fall back to --cb-* semantic vars so dark / light themes stay consistent.
  5. Header / footer slots. Compose any narration, prediction gate, or hint above or below the rows via the header and footer props — the primitive stays scope-narrow.
  6. Sanitised input. Negative or non-integer fences / panels are clamped to zero and floored — the component never throws on garbage input.
  7. 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

PropTypeDefaultDescription
fencesnumberrequiredNumber of fence posts to render. Clamped to >= 0 and floored.
panelsnumberrequiredNumber of panels (gaps) to render. Clamped to >= 0 and floored.
answernumberExpected panel count. When supplied and not equal to panels, the mismatch badge appears.
fenceLabelstring"fences"Label rendered above the fence row.
panelLabelstring"panels"Label rendered above the panel row.
showIndicesbooleantrueRender 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.
headerReactNodeContent rendered above the fence row.
footerReactNodeContent rendered below the panel row.
transitionTransitionSPRINGS.smoothOverride post / panel transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The fence row and panel row are each wrapped in a role="group" with an aria-labelledby pointing 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 an aria-label describing the visual.
  • When panels differs from answer, the mismatch badge carries an aria-label announcing 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 expected answer, sanitised input, five tones — and lets the caller compose any acts, validation, narration, or sound on top via the header / footer slots.