InvariantTracker
A live N-row panel for the derived numbers a student must keep honest. The caller passes a set of invariants — each a triple of id, label, and a pure test(state) function — plus an arbitrary state blob. The component evaluates every test on every render and surfaces a pass / fail badge, the live value, and an optional reason for the failure.
Pure visualisation primitive — it does not own the state machine, the prediction gates, or the gate-style reveal of named formulas the source lesson layered on. Generic enough for n-queens conflict counts, two-pointer width identities, prefix-sum equalities, diagonal DP indices, monotonic-stack tightness, or any lesson where the student must watch one or more derived numbers stay honest as the underlying state mutates.
[1,3,4,7,9,11,14] for target 12Installation
npx shadcn@latest add https://craftbits.dev/r/invariant-tracker.jsonUsage
import { InvariantTracker } from "@craft-bits/core";
const invariants = [
{ id: "ordering", label: "l <= r", test: (s) => s.l <= s.r },
];
<InvariantTracker invariants={invariants} state={{ l: 0, r: 5 }} />Returning a richer result — surface the live value and an explanation when the invariant fails:
const invariants = [
{
id: "sum",
label: "arr[l] + arr[r] = target",
test: ({ arr, l, r, target }) => {
const sum = arr[l] + arr[r];
return {
ok: sum === target,
value: sum,
reason:
sum < target
? "Sum is too small — widen by moving l right."
: sum > target
? "Sum is too large — narrow by moving r left."
: undefined,
};
},
},
];
<InvariantTracker invariants={invariants} state={{ arr, l, r, target }} />Multiple invariants — every row is evaluated against the same state, and the header strip reports all N hold vs K / N failing:
<InvariantTracker
invariants={[
{ id: "ordering", label: "l <= r", test: (s) => s.l <= s.r },
{ id: "width", label: "r - l", test: (s) => ({ ok: true, value: s.r - s.l }) },
{ id: "in-bounds", label: "0 <= l, r < n", test: (s) => 0 <= s.l && s.r < s.n },
]}
state={state}
/>With descriptions — a ? button reveals a one-line prompt under each row so the caller can phrase the "what should hold and why":
<InvariantTracker
invariants={[
{
id: "anti-diagonal",
label: "row + col distinct",
description: "Each anti-diagonal can host at most one queen.",
test: ({ queens }) => ({
ok: new Set(queens.map((q) => q.row + q.col)).size === queens.length,
}),
},
]}
state={{ queens }}
/>Understanding the component
- Caller owns the state.
stateis an opaque blob — the component does not inspect it, only forwards it to each invariant'stestfunction. The state type is captured by theInvariantTracker<TState>generic so the caller'stestsignatures stay strongly typed. - Tests are pure. Each
test(state)is called on every render. Return a boolean for the simple "holds / violated" case, or anInvariantTrackerResultto surface the live value, a fail reason, or both. Side effects intestwill fire on every render — keep them pure. - Header reports the rollup. Above the rows, the component renders an
all N holdchip when every invariant passes and aK / N failingchip otherwise. The chip pulses once on transition from holds to failing. - Per-row badge + value + reason. Each row shows a circular
check/crossbadge in the accent (success / error) tone, the row label, the live value if the test returned one, and the fail reason beneath the label if the test returned one and the invariant is currently violated. - Optional description tooltip. Pass a
descriptionand a?button renders next to the label. Click to expand a one-line description strip — useful for the "what should hold and why" prompt without leaking the answer into the row chrome. - Reduced motion. Row entries, the rollup chip pulse, the badge pop, and the description expand all collapse to instant under
prefers-reduced-motion: reduce.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
invariants | ReadonlyArray<InvariantTrackerInvariant<TState>> | required | The invariants to evaluate. Each is { id, label, test, description? }. Order = render order. |
state | TState | required | The state blob passed to every test. Opaque to the tracker; typed by the generic. |
title | ReactNode | — | Content rendered above the header. |
footer | ReactNode | — | Content rendered below the rows. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Semantic accent for the chrome. Row pass / fail tones always come from success / error. |
emptyLabel | string | "No invariants tracked yet." | Empty-state copy when invariants is empty. |
transition | Transition | SPRINGS.smooth | Override transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The row container is a
role="list"with anaria-labelledbypointing at the "invariants" header label; each row is arole="listitem"with an explicitaria-labelnaming the invariant id and whether it currently holds. - The rollup chip uses
aria-live="polite"and carries anaria-labelthat names the failing count and the total, so screen-reader users hear every flip to and from the "all hold" state. - The pass / fail badge is
aria-hiddenso the badge glyph (check / cross) is not double-announced after the row label. - The optional description button is a
<button type="button">witharia-expanded, a 24×24 hit target, and a label naming the invariant whose description it toggles. - An off-screen
aria-live="polite"summary repeats the full status on every render so consumers of the live region get the same narration sighted users see. - The component exposes
data-toneanddata-all-okon the root, plusdata-invariantanddata-stateon each row, so consumer apps can hook custom styles or assistive tooling. - Motion respects
prefers-reduced-motion: reduce— row entries, the rollup pulse, the badge pop, and the description expand all collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/observation/InvariantTracker.tsx). The source was a stateless N-column numeric table with an opaque-to-named header morph driven by aformulasRevealedgate, a per-column?tooltip affordance that auto-closed after 4s, a candidate row rendered beneath the seated rows in a pulsing dashed state, and per-column highlight maps for targeted cell emphasis. The library extract keeps the live evaluation of one or more invariants against a caller-owned state blob and the pass / fail / value / reason affordance, and lets the caller compose the gate-driven formula reveal, the candidate preview row, the per-cell highlights, the audio cues, and the prediction prompts on top via thetitle/footerslots.