Invariant Forge

A build-an-invariant primitive. The caller defines a bank of fragment chips and one or more clauses — each clause is a labelled sentence template (literal text chunks interleaved with positional slots). The student taps a fragment to select it, then taps a slot to drop it in. Tap a filled slot to clear it. Controlled (placements + onPlacementsChange) and uncontrolled (defaultPlacements) on the Radix pattern.

Generic enough to cover any "assemble the loop invariant from fragments" interaction — binary-search safety + progress, partition correctness, monotonic-stack invariants, sliding-window predicates — without baking in scoring, audio, or stress-test phases. The caller compares the emitted placements record against its accepted answers.

Safety

If is in the array, then it is in .

Progress

The interval .

Customize
Highlight
1
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/invariant-forge.json

Usage

import {
  InvariantForge,
  type InvariantClause,
  type InvariantFragment,
} from "@craft-bits/core";
 
const fragments: InvariantFragment[] = [
  { id: "lo", text: "lo" },
  { id: "hi", text: "hi" },
  { id: "target", text: "target" },
  { id: "interval", text: "[lo..hi]" },
  { id: "shrink", text: "shrinks by 1" },
];
 
const clauses: InvariantClause[] = [
  {
    id: "safety",
    label: "Safety",
    template: [
      "If ",
      { id: "safety-1" },
      " is in the array, then it is in ",
      { id: "safety-2" },
      ".",
    ],
  },
  {
    id: "progress",
    label: "Progress",
    template: [
      "The interval ",
      { id: "progress-1" },
      " ",
      { id: "progress-2" },
      ".",
    ],
  },
];
 
<InvariantForge fragments={fragments} clauses={clauses} />

Controlled — parent owns the placements record and validates against accepted answers:

const [placements, setPlacements] = useState({});
 
<InvariantForge
  fragments={fragments}
  clauses={clauses}
  placements={placements}
  onPlacementsChange={setPlacements}
/>

Allow a fragment to occupy multiple slots at once (invariants that name the same variable twice):

<InvariantForge
  fragments={fragments}
  clauses={clauses}
  defaultPlacements={{ "safety-1": "target", "safety-2": "interval" }}
  allowDuplicates
/>

Read-only summary — show the accepted invariant without letting the user edit:

<InvariantForge
  fragments={fragments}
  clauses={clauses}
  placements={correctAnswer}
  editable={false}
  tone="success"
/>

Understanding the component

  1. Tap, then drop. A fragment press selects the chip; the next slot press places it. Tapping the same fragment again clears the selection. Tapping a filled slot clears that slot.
  2. Clauses are templates, not strings. Each clause's template is an interleaved array of literal text chunks (typed as string) and slot objects (typed as InvariantSlot). Literal chunks render as plain prose; slot objects render as drop targets. The caller controls every word of the sentence, so the same primitive can render English, math notation, or pseudo-code without changes.
  3. Controlled + uncontrolled. placements + onPlacementsChange is the Radix controlled pattern. defaultPlacements lets the component own placements internally. If neither is provided, all slots start empty.
  4. Placement shape. placements is a record keyed by slot id — null for empty, otherwise the fragment ID. The component initialises missing keys to null so caller bugs never crash the layout.
  5. Duplicates. By default, placing a fragment clears any earlier slot it occupied — one chip, one slot, the bank dims once the fragment is in play. Set allowDuplicates to keep the chip live; multiple slots can hold the same fragment.
  6. Hit target. Every chip and slot enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) — short fragments still tap on mobile.
  7. Reduced motion. Slot enter, chip bounce, and the placeholder breathing collapse to instant under prefers-reduced-motion: reduce. The placements still update; only the motion drops.

Props

PropTypeDefaultDescription
fragmentsInvariantFragment[]requiredBank chips. Each carries an id, text, and optional clauseId hint.
clausesInvariantClause[]requiredLabelled sentence templates. Each template interleaves text chunks and InvariantSlot drop targets.
placementsRecord<string, string | null>Controlled placements keyed by slot id. Pair with onPlacementsChange.
defaultPlacementsRecord<string, string | null>Uncontrolled initial placements.
onPlacementsChange(next: Record<string, string | null>) => voidFires with the next placements record on every placement or clear.
allowDuplicatesbooleanfalseAllow a single fragment to occupy multiple slots simultaneously.
editablebooleantrueWhen false, clauses and bank are non-interactive.
tone"default" | "accent" | "success" | "warning" | "error""accent"Highlight palette for selection, fills, and focus ring.
headerReactNodeContent rendered above the clauses.
footerReactNodeContent rendered below the bank.
transitionTransitionSPRINGS.smoothOverride slot / chip transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The clause column is role="list"; each clause is a role="listitem" with an explicit data-clause attribute carrying the clause id, so consumer apps can score per-clause without parsing labels.
  • The fragment bank is role="listbox" with aria-activedescendant pointing at the currently held chip; each chip is a role="option" button with aria-selected reflecting the held state.
  • Every slot button carries an explicit aria-label that announces the clause label and either its current fragment or "select a fragment first".
  • Every chip and slot is keyboard activated — Tab to focus, Space or Enter to pick / place / clear — 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.
  • Slots expose data-state (empty / armed / filled); chips expose data-state (rest / selected / placed) so consumer apps can hook custom styles or assistive tooling.
  • Tone is never the only signal — placed and selected states layer fill, stroke, and ring changes so colour-blind users see the distinction.
  • Motion respects prefers-reduced-motion: reduce — slot enter, chip bounce, and the empty-slot breathing collapse to instant.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/construction/InvariantForge.tsx). The source was a 2300-line four-act lesson component bundling a stuck-loop animation, prediction gates, drag-and-drop with spatial hit-testing, a probe-budget stress-test phase, a buggy-code line-tap puzzle, audio cues, and an act-by-act scoring rollup. The library extract keeps only the construction primitive — fragment bank, clause templates with positional slots, controlled / uncontrolled placements, optional duplicates, five tones — and lets the caller compose any acts, validation, stress-tests, or sound on top via the header / footer slots.