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.
If is in the array, then it is in .
The interval .
Installation
npx shadcn@latest add https://craftbits.dev/r/invariant-forge.jsonUsage
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
- 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.
- Clauses are templates, not strings. Each clause's
templateis an interleaved array of literal text chunks (typed asstring) and slot objects (typed asInvariantSlot). 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. - Controlled + uncontrolled.
placements+onPlacementsChangeis the Radix controlled pattern.defaultPlacementslets the component own placements internally. If neither is provided, all slots start empty. - Placement shape.
placementsis a record keyed by slot id —nullfor empty, otherwise the fragment ID. The component initialises missing keys tonullso caller bugs never crash the layout. - 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
allowDuplicatesto keep the chip live; multiple slots can hold the same fragment. - Hit target. Every chip and slot enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) — short fragments still tap on mobile.
- 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
| Prop | Type | Default | Description |
|---|---|---|---|
fragments | InvariantFragment[] | required | Bank chips. Each carries an id, text, and optional clauseId hint. |
clauses | InvariantClause[] | required | Labelled sentence templates. Each template interleaves text chunks and InvariantSlot drop targets. |
placements | Record<string, string | null> | — | Controlled placements keyed by slot id. Pair with onPlacementsChange. |
defaultPlacements | Record<string, string | null> | — | Uncontrolled initial placements. |
onPlacementsChange | (next: Record<string, string | null>) => void | — | Fires with the next placements record on every placement or clear. |
allowDuplicates | boolean | false | Allow a single fragment to occupy multiple slots simultaneously. |
editable | boolean | true | When false, clauses and bank are non-interactive. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight palette for selection, fills, and focus ring. |
header | ReactNode | — | Content rendered above the clauses. |
footer | ReactNode | — | Content rendered below the bank. |
transition | Transition | SPRINGS.smooth | Override slot / chip transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The clause column is
role="list"; each clause is arole="listitem"with an explicitdata-clauseattribute carrying the clause id, so consumer apps can score per-clause without parsing labels. - The fragment bank is
role="listbox"witharia-activedescendantpointing at the currently held chip; each chip is arole="option"button witharia-selectedreflecting the held state. - Every slot button carries an explicit
aria-labelthat 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 exposedata-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 theheader/footerslots.