Construction Primitives

A five-piece set of lesson-chrome building blocks shared by every Predict / Challenge widget in the source projects. ModeStrip switches between modes, ChallengeBtn is the verb, FeedbackBadge reports the outcome of a check, ScoreDots tracks the running history, and DoneCard closes the run. Each primitive does one thing, picks tone from the cb-* semantic tokens, and respects prefers-reduced-motion.

Round 4 of 5

Nice!
2 correct of 3 (of 5)
Customize
Mode
predict
Progress
5
3

Installation

npx shadcn@latest add https://craftbits.dev/r/construction-primitives.json

Usage

import {
  ChallengeBtn,
  DoneCard,
  FeedbackBadge,
  ModeStrip,
  ScoreDots,
  type ConstructionOutcome,
} from "@craft-bits/edu";
 
const MODES = [
  { key: "explore", label: "Explore" },
  { key: "predict", label: "Predict" },
  { key: "challenge", label: "Challenge" },
] as const;
 
function PredictRound() {
  const [mode, setMode] = useState("predict");
  const [results, setResults] = useState([]);
  const [last, setLast] = useState(null);
  const total = 5;
  const done = results.length >= total;
  const correct = results.filter((r) => r === "correct").length;
 
  return (
    <>
      <ModeStrip modes={MODES} current={mode} onChange={setMode} />
      {done ? (
        <DoneCard score={correct} total={total} onRetry={() => setResults([])} />
      ) : (
        <ChallengeBtn onClick={check}>Check</ChallengeBtn>
      )}
      <FeedbackBadge show={last !== null} outcome={last}>Nice!</FeedbackBadge>
      <ScoreDots results={results} total={total} />
    </>
  );
}

Anatomy

  • ModeStriprole="radiogroup" with one role="radio" button per option. Selected pill sits on a cb-accent-tinted background with an inset 1px ring; the rest stay transparent and lift to cb-bg-muted on hover. The generic <T extends string> keeps current and onChange strictly typed against the option set.
  • ChallengeBtn — two tones, one shape, one size. Primary uses cb-accent; secondary uses a muted surface. Press feedback is whileTap: { scale: 0.96 } on SPRINGS.snap, short-circuited under reduced motion. Min hit area is 44px tall.
  • FeedbackBadge — outcome chip wrapped in AnimatePresence. Correct outcomes pop with a tiny overshoot; incorrect outcomes pulse a 6-frame x-axis shake. Both routes share SPRINGS.snap. Reduced motion collapses both to a no-overshoot fade.
  • ScoreDots — ordered row of small circles. Each dot paints in cb-success or cb-error, and correct dots get a low-opacity halo via box-shadow. Optional total reserves placeholder dots at the right end. An SR-only summary line reports the running tally ("3 correct of 5").
  • DoneCard — closing card with role="status" and aria-live="polite". Reports {score}/{total} (or "Perfect!") and the percentage, plus an optional retry CTA. Compose any extra summary content via children.

Props

ModeStrip

PropTypeDefaultDescription
modesReadonlyArray<{ key: T; label: ReactNode; disabled?: boolean }>requiredMode options. Order is preserved.
currentTrequiredSelected mode key.
onChange(mode: T) => voidrequiredFired when the user picks a different mode.
ariaLabelstring'Mode'Accessible name for the strip.

ChallengeBtn

PropTypeDefaultDescription
secondarybooleanfalseRender the muted tone.
disabledbooleanfalseNative HTML disabled.
childrenReactNoderequiredButton label.

FeedbackBadge

PropTypeDefaultDescription
showbooleanrequiredVisibility. Mounts / unmounts via AnimatePresence.
outcome'correct' | 'incorrect'requiredDrives the badge tone and animation.
childrenReactNoderequiredBadge content.

ScoreDots

PropTypeDefaultDescription
resultsReadonlyArray<'correct' | 'incorrect' | boolean>requiredOutcomes already settled.
totalnumberOptional total slots. Greater than results.length fills the remainder with placeholders.
ariaLabelstring'Score'Accessible name for the row.

DoneCard

PropTypeDefaultDescription
scorenumberrequiredCorrect rounds.
totalnumberrequiredTotal rounds.
onRetry() => voidOptional retry handler. When omitted, the retry button is hidden.
retryLabelReactNode'Try again'Label for the retry button.
titleReactNodeHeadline override.
childrenReactNodeOptional body content between the headline and the retry CTA.

Accessibility

  • ModeStrip carries role="radiogroup"; each pill carries role="radio" and aria-checked, so screen readers announce the active mode alongside the rest of the set.
  • ChallengeBtn is a native <button> — keyboard activation, focus ring, and disabled semantics all come free.
  • FeedbackBadge carries data-outcome for downstream styling hooks. Meaning is reinforced by the visible text content, not colour alone.
  • ScoreDots exposes an SR-only summary so assistive tech reports the running tally without scanning the visual circles.
  • DoneCard is role="status" + aria-live="polite", so the closing summary reads automatically the moment the run completes.
  • Motion is transform / opacity / colour only — never width / height / top / left. prefers-reduced-motion: reduce collapses every animation to an instant resting state.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/chrome/ConstructionPrimitives.tsx). The source file shipped the five UI primitives alongside the usePredictRounds and useChallengeState round-state hooks; the hooks live separately in @craft-bits/core so their state machines can be reused without dragging the chrome along.
  • The source's ModePill helper has been folded into ModeStrip to keep the public surface at five components. The accent-rail colours have been retoned from var(--color-accent-*) to cb-accent, and the TIMING.correct.scaleBounce ad-hoc transition is now SPRINGS.snap to share the library's motion vocabulary.