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.

Two-pointer search over [1,3,4,7,9,11,14] for target 12
invariants1 / 3 failing
l <= r
0 <= 6
r - l
6
arr[l] + arr[r] = target
15
Sum is too large — narrow by moving r left.
1 of 3 invariants failing: sum-vs-target.

Installation

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

Usage

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

  1. Caller owns the state. state is an opaque blob — the component does not inspect it, only forwards it to each invariant's test function. The state type is captured by the InvariantTracker<TState> generic so the caller's test signatures stay strongly typed.
  2. Tests are pure. Each test(state) is called on every render. Return a boolean for the simple "holds / violated" case, or an InvariantTrackerResult to surface the live value, a fail reason, or both. Side effects in test will fire on every render — keep them pure.
  3. Header reports the rollup. Above the rows, the component renders an all N hold chip when every invariant passes and a K / N failing chip otherwise. The chip pulses once on transition from holds to failing.
  4. Per-row badge + value + reason. Each row shows a circular check / cross badge 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.
  5. Optional description tooltip. Pass a description and 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.
  6. 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

PropTypeDefaultDescription
invariantsReadonlyArray<InvariantTrackerInvariant<TState>>requiredThe invariants to evaluate. Each is { id, label, test, description? }. Order = render order.
stateTStaterequiredThe state blob passed to every test. Opaque to the tracker; typed by the generic.
titleReactNodeContent rendered above the header.
footerReactNodeContent 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.
emptyLabelstring"No invariants tracked yet."Empty-state copy when invariants is empty.
transitionTransitionSPRINGS.smoothOverride transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The row container is a role="list" with an aria-labelledby pointing at the "invariants" header label; each row is a role="listitem" with an explicit aria-label naming the invariant id and whether it currently holds.
  • The rollup chip uses aria-live="polite" and carries an aria-label that 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-hidden so the badge glyph (check / cross) is not double-announced after the row label.
  • The optional description button is a <button type="button"> with aria-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-tone and data-all-ok on the root, plus data-invariant and data-state on 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 a formulasRevealed gate, 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 the title / footer slots.